Commit f5c890f9 authored by Max Alekseenko's avatar Max Alekseenko

merge main

parents e6975fa4 65e6b205
## 🚀 New Features
- Description of the new feature 1.
- Description of the new feature 2.
## 🐛 Bug Fixes
- Description of the bug fix 1.
- Description of the bug fix 2.
## ⚡ Performance Improvements
- Description of the performance improvement 1.
- Description of the performance improvement 2.
## 📦 Dependencies updates
- Updated dependency: PackageName 1 to version x.x.x.
- Updated dependency: PackageName 2 to version x.x.x.
## ✨ Other Changes
- Another minor change 1.
- Another minor change 2.
## 🚨 Changes in ENV variables
- Added new environment variable: ENV_VARIABLE_NAME with value.
- Updated existing environment variable: ENV_VARIABLE_NAME to new value.
**Full list of the ENV variables**: [v1.2.3](https://github.com/blockscout/frontend/blob/v1.2.3/docs/ENVS.md)
## 🦄 New Contributors
- @contributor1 made their first contribution in https://github.com/blockscout/frontend/pull/1
- @contributor2 made their first contribution in https://github.com/blockscout/frontend/pull/2
---
**Full Changelog**: https://github.com/blockscout/frontend/compare/v1.2.2...v1.2.3
......@@ -349,12 +349,20 @@ const accountSchema = yup
then: (schema) => schema.test(urlTest).required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_LOGOUT_URL cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not set to "true"'),
}),
});
const adminServiceSchema = yup
.object()
.shape({
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup
.string()
.when('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', {
is: (value: boolean) => value,
.when([ 'NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', 'NEXT_PUBLIC_MARKETPLACE_ENABLED' ], {
is: (value1: boolean, value2: boolean) => value1 || value2,
then: (schema) => schema.test(urlTest),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not set to "true"'),
otherwise: (schema) => schema.max(
-1,
'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED or NEXT_PUBLIC_MARKETPLACE_ENABLED is not set to "true"',
),
}),
});
......@@ -631,6 +639,7 @@ const schema = yup
.concat(rollupSchema)
.concat(beaconChainSchema)
.concat(bridgedTokensSchema)
.concat(sentrySchema);
.concat(sentrySchema)
.concat(adminServiceSchema);
export default schema;
#!/bin/bash
secrets_file=".env.secrets"
test_folder="./test"
common_file="${test_folder}/.env.common"
......@@ -8,7 +7,6 @@ common_file="${test_folder}/.env.common"
export NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD)
export NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0)
../../scripts/collect_envs.sh ../../../docs/ENVS.md
cp ../../../.env.example ${secrets_file}
# Copy test assets
mkdir -p "./public/assets"
......@@ -26,7 +24,6 @@ validate_file() {
dotenv \
-e $test_file \
-e $common_file \
-e $secrets_file \
yarn run validate -- --silent
if [ $? -eq 0 ]; then
......
NEXT_PUBLIC_SENTRY_DSN=https://sentry.io
NEXT_PUBLIC_AUTH_URL=https://example.com
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://example.com
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx
FAVICON_GENERATOR_API_KEY=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
NEXT_PUBLIC_AD_TEXT_PROVIDER=coinzilla
NEXT_PUBLIC_AD_BANNER_PROVIDER=slise
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
......
NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_AUTH_URL=https://example.com
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://example.com
NEXT_PUBLIC_NETWORK_ID=1
NEXT_PUBLIC_NETWORK_NAME=Testnet
......@@ -50,11 +50,11 @@ frontend:
NEXT_PUBLIC_APP_ENV: development
NEXT_PUBLIC_APP_INSTANCE: review
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE: validation
NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json
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_API_HOST: eth-sepolia.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.k8s-dev.blockscout.com/
NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-sepolia.json
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.png
NEXT_PUBLIC_API_HOST: eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-sepolia.k8s-dev.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/
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
......
......@@ -200,6 +200,8 @@ Settings for meta tags, OG tags and SEO
| `total_reward` | Total block reward |
| `nonce` | Block nonce |
| `miner` | Address of block's miner or validator |
| `L1_status` | Short interpretation of the batch lifecycle (applicable for Rollup chains) |
| `batch` | Batch index (applicable for Rollup chains) |
 
......@@ -234,6 +236,8 @@ Settings for meta tags, OG tags and SEO
| `tx_fee` | Total transaction fee |
| `gas_fees` | Gas fees breakdown |
| `burnt_fees` | Amount of native coin burnt for transaction |
| `L1_status` | Short interpretation of the batch lifecycle (applicable for Rollup chains) |
| `batch` | Batch index (applicable for Rollup chains) |
##### Transaction additional fields list
| Id | Description |
......
<svg viewBox="0 0 240 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M82.667 4H56a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4ZM56 0a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V8a8 8 0 0 0-8-8H56Zm122.667 4H152a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4ZM152 0a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V8a8 8 0 0 0-8-8H152ZM56 52h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H56a4 4 0 0 1-4-4V56a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V56Zm34.667 44H56a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V104a4 4 0 0 0-4-4ZM56 96a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V104a8 8 0 0 0-8-8H56Zm0 52h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H56a4 4 0 0 1-4-4V152a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V152ZM178.667 52H152a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V56a4 4 0 0 0-4-4ZM152 48a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V56a8 8 0 0 0-8-8H152Zm0 52h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H152a4 4 0 0 1-4-4V104a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H152a8 8 0 0 1-8-8V104Zm34.667 44H152a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V152a4 4 0 0 0-4-4ZM152 144a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V152a8 8 0 0 0-8-8H152ZM8 28h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V32a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V32Zm104-4h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H104a4 4 0 0 1-4-4V32a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H104a8 8 0 0 1-8-8V32Zm130.667-4H200a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V32a4 4 0 0 0-4-4ZM200 24a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V32a8 8 0 0 0-8-8H200Zm-96 52h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H104a4 4 0 0 1-4-4V80a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H104a8 8 0 0 1-8-8V80Zm34.667 44H104a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V128a4 4 0 0 0-4-4ZM104 120a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V128a8 8 0 0 0-8-8H104Zm96-44h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H200a4 4 0 0 1-4-4V80a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H200a8 8 0 0 1-8-8V80Zm34.667 44H200a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V128a4 4 0 0 0-4-4ZM200 120a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V128a8 8 0 0 0-8-8H200ZM8 76h26.667a4 4 0 0 1 4 4v26.667a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V80a4 4 0 0 1 4-4Zm-8 4a8 8 0 0 1 8-8h26.667a8 8 0 0 1 8 8v26.667a8 8 0 0 1-8 8H8a8 8 0 0 1-8-8V80Zm34.667 44H8a4 4 0 0 0-4 4v26.667a4 4 0 0 0 4 4h26.667a4 4 0 0 0 4-4V128a4 4 0 0 0-4-4ZM8 120a8 8 0 0 0-8 8v26.667a8 8 0 0 0 8 8h26.667a8 8 0 0 0 8-8V128a8 8 0 0 0-8-8H8Z" fill="url(#empty_search_result_svg__a)"/>
<path d="m135.337 94.376 11.182 11.242-3.694 3.715-11.18-11.245a23.31 23.31 0 0 1-14.666 5.17c-12.971 0-23.498-10.586-23.498-23.629C93.48 66.586 104.008 56 116.979 56c12.971 0 23.499 10.586 23.499 23.629a23.614 23.614 0 0 1-5.141 14.747Zm-5.238-1.948a18.372 18.372 0 0 0 5.157-12.799c0-10.155-8.18-18.378-18.277-18.378-10.099 0-18.276 8.223-18.276 18.378 0 10.153 8.177 18.378 18.276 18.378a18.166 18.166 0 0 0 12.729-5.185l.391-.394Z" fill="#A0AEC0"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 185" fill="none">
<g clip-path="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.307-2.023H6.923A7.2 7.2 0 0 0-.37 5.269v19.385a7.25 7.25 0 0 0 7.293 7.292h19.384a7.25 7.25 0 0 0 7.293-7.292V5.269a7.25 7.25 0 0 0-7.293-7.292Zm-19.384-2.4A9.692 9.692 0 0 0-2.77 5.269v19.385a9.692 9.692 0 0 0 9.693 9.692h19.384A9.692 9.692 0 0 0 36 24.654V5.269a9.693 9.693 0 0 0-9.693-9.692H6.923Zm19.384 54.092H6.923A7.2 7.2 0 0 0-.37 56.962v19.384a7.25 7.25 0 0 0 7.293 7.292h19.384a7.25 7.25 0 0 0 7.293-7.292V56.961a7.25 7.25 0 0 0-7.293-7.292Zm-19.384-2.4a9.693 9.693 0 0 0-9.693 9.693v19.384a9.692 9.692 0 0 0 9.693 9.692h19.384A9.692 9.692 0 0 0 36 76.346V56.961a9.693 9.693 0 0 0-9.693-9.692H6.923Zm0 54.093h19.384a7.249 7.249 0 0 1 7.293 7.292v19.384a7.25 7.25 0 0 1-7.293 7.293H6.923a7.209 7.209 0 0 1-5.184-2.108 7.208 7.208 0 0 1-2.109-5.185v-19.384a7.249 7.249 0 0 1 7.293-7.292Zm-9.693 7.292a9.693 9.693 0 0 1 9.693-9.692h19.384A9.694 9.694 0 0 1 36 108.654v19.384a9.694 9.694 0 0 1-9.693 9.693H6.923a9.691 9.691 0 0 1-9.693-9.693v-19.384Zm29.077 44.4H6.923a7.19 7.19 0 0 0-5.184 2.108 7.208 7.208 0 0 0-2.109 5.184v19.385a7.25 7.25 0 0 0 7.293 7.292h19.384a7.25 7.25 0 0 0 7.293-7.292v-19.385a7.25 7.25 0 0 0-7.293-7.292Zm-19.384-2.4a9.691 9.691 0 0 0-9.693 9.692v19.385a9.691 9.691 0 0 0 9.693 9.692h19.384A9.691 9.691 0 0 0 36 179.731v-19.385a9.691 9.691 0 0 0-9.693-9.692H6.923Zm51.692-126.83H78a7.25 7.25 0 0 1 7.292 7.291V50.5A7.25 7.25 0 0 1 78 57.792H58.615a7.2 7.2 0 0 1-7.292-7.292V31.115a7.249 7.249 0 0 1 7.292-7.292Zm-9.692 7.291a9.692 9.692 0 0 1 9.692-9.692H78a9.692 9.692 0 0 1 9.692 9.692V50.5A9.692 9.692 0 0 1 78 60.192H58.615a9.692 9.692 0 0 1-9.692-9.692V31.115ZM78 75.515H58.615a7.2 7.2 0 0 0-7.292 7.293v19.384a7.25 7.25 0 0 0 7.292 7.293H78a7.25 7.25 0 0 0 7.292-7.293v-2.123c.39 1.933.977 3.819 1.754 5.631A9.689 9.689 0 0 1 78 111.885H58.615a9.695 9.695 0 0 1-9.692-9.693V82.808a9.692 9.692 0 0 1 9.692-9.693H78c4.061 0 7.569 2.493 9.046 6.185-.83 1.754-1.385 3.692-1.846 5.538v-2.03c0-4.062-3.139-7.293-7.2-7.293Zm61.384-12.83V56.96a9.694 9.694 0 0 0-9.692-9.692h-19.385a9.693 9.693 0 0 0-9.692 9.693v5.723l2.4-1.477v-4.246a7.25 7.25 0 0 1 7.292-7.293h19.385a7.249 7.249 0 0 1 7.292 7.293v4.246l2.4 1.477Zm13.57 43.015a9.691 9.691 0 0 0 9.046 6.185h19.384a9.696 9.696 0 0 0 9.693-9.693V82.808a9.695 9.695 0 0 0-9.693-9.693H162a9.69 9.69 0 0 0-9.046 6.185c.83 1.754 1.384 3.692 1.846 5.538v-2.03c0-4.062 3.138-7.293 7.2-7.293h19.384a7.25 7.25 0 0 1 7.293 7.293v19.384a7.25 7.25 0 0 1-7.293 7.293H162a7.188 7.188 0 0 1-5.184-2.109 7.188 7.188 0 0 1-2.109-5.184v-2.123a28.602 28.602 0 0 1-1.753 5.631Zm-52.339 16.615v5.816a9.69 9.69 0 0 0 9.692 9.692h19.385a9.692 9.692 0 0 0 9.692-9.692v-5.723l-2.4 1.384v4.339a7.249 7.249 0 0 1-7.292 7.292h-19.385a7.192 7.192 0 0 1-5.217-2.14 7.199 7.199 0 0 1-2.075-5.245v-4.153l-2.4-1.385v-.185Zm-42 4.893H78a7.249 7.249 0 0 1 7.292 7.292v19.385A7.25 7.25 0 0 1 78 161.177H58.615a7.21 7.21 0 0 1-5.184-2.108 7.209 7.209 0 0 1-2.108-5.184V134.5a7.248 7.248 0 0 1 7.292-7.292Zm-9.692 7.292a9.693 9.693 0 0 1 9.692-9.692H78a9.694 9.694 0 0 1 9.692 9.692v19.385A9.693 9.693 0 0 1 78 163.577H58.615a9.69 9.69 0 0 1-9.692-9.692V134.5ZM129.692-2.023h-19.385a7.198 7.198 0 0 0-7.292 7.292v19.385a7.25 7.25 0 0 0 7.292 7.292h19.385a7.249 7.249 0 0 0 7.292-7.292V5.269a7.249 7.249 0 0 0-7.292-7.292Zm-19.385-2.4a9.693 9.693 0 0 0-9.692 9.692v19.385a9.69 9.69 0 0 0 9.692 9.692h19.385a9.693 9.693 0 0 0 9.692-9.692V5.269a9.694 9.694 0 0 0-9.692-9.692h-19.385Zm-7.292 164.769a7.25 7.25 0 0 1 7.292-7.292h19.385a7.249 7.249 0 0 1 7.292 7.292v19.385a7.249 7.249 0 0 1-7.292 7.292h-19.385a7.207 7.207 0 0 1-5.184-2.108 7.216 7.216 0 0 1-2.108-5.184v-19.385Zm-2.4 0a9.69 9.69 0 0 1 9.692-9.692h19.385a9.692 9.692 0 0 1 9.692 9.692v19.385a9.693 9.693 0 0 1-9.692 9.692h-19.385a9.691 9.691 0 0 1-9.692-9.692v-19.385Zm80.769-136.523H162a7.206 7.206 0 0 0-5.184 2.108 7.195 7.195 0 0 0-2.109 5.184V50.5A7.25 7.25 0 0 0 162 57.792h19.384a7.25 7.25 0 0 0 7.293-7.292V31.115a7.25 7.25 0 0 0-7.293-7.292ZM162 21.423a9.691 9.691 0 0 0-9.693 9.692V50.5A9.695 9.695 0 0 0 162 60.192h19.384a9.693 9.693 0 0 0 9.693-9.692V31.115a9.695 9.695 0 0 0-9.693-9.692H162Zm19.384 105.785H162a7.186 7.186 0 0 0-5.184 2.108 7.193 7.193 0 0 0-2.109 5.184v19.385a7.25 7.25 0 0 0 7.293 7.292h19.384a7.25 7.25 0 0 0 7.293-7.292V134.5a7.249 7.249 0 0 0-7.293-7.292Zm-19.384-2.4a9.695 9.695 0 0 0-9.693 9.692v19.385a9.696 9.696 0 0 0 9.693 9.692h19.384a9.692 9.692 0 0 0 9.693-9.692V134.5a9.695 9.695 0 0 0-9.693-9.692H162ZM213.692-2.023h19.385a7.25 7.25 0 0 1 7.292 7.292v19.385a7.25 7.25 0 0 1-7.292 7.292h-19.385a7.198 7.198 0 0 1-7.292-7.292V5.269a7.249 7.249 0 0 1 7.292-7.292ZM204 5.269a9.691 9.691 0 0 1 9.692-9.692h19.385a9.693 9.693 0 0 1 9.692 9.692v19.385a9.69 9.69 0 0 1-9.692 9.692h-19.385A9.69 9.69 0 0 1 204 24.654V5.269Zm29.077 44.4h-19.385a7.198 7.198 0 0 0-7.292 7.293v19.384a7.249 7.249 0 0 0 7.292 7.292h19.385a7.25 7.25 0 0 0 7.292-7.292V56.961a7.25 7.25 0 0 0-7.292-7.292Zm-19.385-2.4A9.69 9.69 0 0 0 204 56.962v19.384a9.69 9.69 0 0 0 9.692 9.692h19.385a9.693 9.693 0 0 0 9.692-9.692V56.961a9.691 9.691 0 0 0-9.692-9.692h-19.385Zm0 54.093h19.385a7.249 7.249 0 0 1 7.292 7.292v19.384a7.25 7.25 0 0 1-7.292 7.293h-19.385a7.207 7.207 0 0 1-5.184-2.108 7.2 7.2 0 0 1-2.108-5.185v-19.384a7.248 7.248 0 0 1 7.292-7.292ZM204 108.654a9.692 9.692 0 0 1 9.692-9.692h19.385a9.694 9.694 0 0 1 9.692 9.692v19.384a9.692 9.692 0 0 1-9.692 9.693h-19.385a9.692 9.692 0 0 1-9.692-9.693v-19.384Zm29.077 44.4h-19.385a7.189 7.189 0 0 0-5.184 2.108 7.2 7.2 0 0 0-2.108 5.184v19.385a7.249 7.249 0 0 0 7.292 7.292h19.385a7.25 7.25 0 0 0 7.292-7.292v-19.385a7.25 7.25 0 0 0-7.292-7.292Zm-19.385-2.4a9.69 9.69 0 0 0-9.692 9.692v19.385a9.69 9.69 0 0 0 9.692 9.692h19.385a9.691 9.691 0 0 0 9.692-9.692v-19.385a9.69 9.69 0 0 0-9.692-9.692h-19.385Z" fill="url(#b)"/>
<path d="M87.231 68.5c1.385-1.846 4.154-.923 4.154 1.477v.37a6.461 6.461 0 0 0 6.462 6.46h44.307a6.461 6.461 0 0 0 6.462-6.46v-.37c0-2.308 2.769-3.416 4.154-1.477.276.37.461.923.461 1.477v.37a11.078 11.078 0 0 1-11.077 11.076H97.847a11.077 11.077 0 0 1-11.078-11.077v-.37c0-.553.185-1.107.462-1.476Zm65.539 48c.276-.369.461-.923.461-1.477v-.369a11.08 11.08 0 0 0-11.077-11.077H97.847a11.078 11.078 0 0 0-11.078 11.077v.369c0 .554.185 1.108.462 1.477 1.385 1.846 4.154.923 4.154-1.477v-.369a6.461 6.461 0 0 1 6.462-6.462h44.307a6.46 6.46 0 0 1 6.462 6.462v.369c0 2.308 2.769 3.415 4.154 1.477Z" fill="#A0AEC0" fill-opacity=".5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M153.231 92.5a33.233 33.233 0 0 1-45.948 30.701 33.237 33.237 0 0 1-17.984-17.984A33.233 33.233 0 0 1 120 59.269 33.232 33.232 0 0 1 153.231 92.5Zm-5.262 33.877a3.963 3.963 0 0 0-4.984-.369 40.618 40.618 0 0 1-54.212-7.54 40.613 40.613 0 0 1 2.52-54.675 40.614 40.614 0 0 1 62.215 51.692c-1.108 1.569-.923 3.692.369 4.984l18.185 18.185a3.693 3.693 0 0 1 0 5.169l-.739.739a3.693 3.693 0 0 1-5.169 0l-18.185-18.185Z" fill="#A0AEC0"/>
</g>
<defs>
<radialGradient id="empty_search_result_svg__a" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="rotate(103.716 25.035 85.404) scale(58.3301 137.703)">
<stop offset=".081" stop-color="#CBD5E0" stop-opacity="0"/>
<stop offset=".563" stop-color="#CBD5E0" stop-opacity=".54"/>
<radialGradient id="b" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 96.9231 -119.218 0 116.449 92.5)">
<stop stop-color="#CBD5E0" stop-opacity=".8"/>
<stop offset="1" stop-color="#CBD5E0" stop-opacity="0"/>
</radialGradient>
<clipPath id="a">
<path fill="#fff" transform="translate(0 .192)" d="M0 0h240v184.615H0z"/>
</clipPath>
</defs>
</svg>
......@@ -20,6 +20,7 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr
'appID',
'logoURL',
'text',
'tagUrl',
'tooltipIcon',
'tooltipTitle',
'tooltipDescription',
......
......@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures {
test_value: string;
security_score_exp: boolean;
action_button_exp: boolean;
}
......
export default function makePrettyLink(url: string | undefined): { url: string; domain: string } | undefined {
try {
const urlObj = new URL(url ?? '');
return {
url: urlObj.href,
domain: urlObj.hostname,
};
} catch (error) {}
}
......@@ -109,6 +109,10 @@ Type extends EventTypes.PAGE_WIDGET ? (
'Type': 'Action button';
'Info': string;
'Source': 'Txn' | 'NFT collection' | 'NFT item';
} | {
'Type': 'Address tag';
'Info': string;
'URL': string;
}
) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
......
......@@ -28,6 +28,7 @@ SocketMessage.AddressTxs |
SocketMessage.AddressTxsPending |
SocketMessage.AddressTokenTransfer |
SocketMessage.AddressChangedBytecode |
SocketMessage.AddressFetchedBytecode |
SocketMessage.SmartContractWasVerified |
SocketMessage.TokenTransfers |
SocketMessage.TokenTotalSupply |
......@@ -64,6 +65,7 @@ export namespace SocketMessage {
export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transactions: Array<Transaction> }>;
export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfers: Array<TokenTransfer> }>;
export type AddressChangedBytecode = SocketMessageParamsGeneric<'changed_bytecode', Record<string, never>>;
export type AddressFetchedBytecode = SocketMessageParamsGeneric<'fetched_bytecode', { fetched_bytecode: string }>;
export type SmartContractWasVerified = SocketMessageParamsGeneric<'smart_contract_was_verified', Record<string, never>>;
export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>;
export type TokenTotalSupply = SocketMessageParamsGeneric<'total_supply', {total_supply: number }>;
......
import type { UseAccountReturnType } from 'wagmi';
import { useAccount } from 'wagmi';
import config from 'configs/app';
function useAccountFallback(): UseAccountReturnType {
return {
address: undefined,
addresses: undefined,
chain: undefined,
chainId: undefined,
connector: undefined,
isConnected: false,
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
status: 'disconnected',
};
}
const hook = config.features.blockchainInteraction.isEnabled ? useAccount : useAccountFallback;
export default hook;
......@@ -30,6 +30,24 @@ export const withEns: AddressParam = {
ens_domain_name: 'kitty.kitty.kitty.cat.eth',
};
export const withNameTag: AddressParam = {
hash: hash,
implementation_name: null,
is_contract: false,
is_verified: null,
name: 'ArianeeStore',
private_tags: [],
watchlist_names: [],
public_tags: [],
ens_domain_name: 'kitty.kitty.kitty.cat.eth',
metadata: {
reputation: null,
tags: [
{ tagType: 'name', name: 'Mrs. Duckie', slug: 'mrs-duckie', ordinal: 0, meta: null },
],
},
};
export const withoutName: AddressParam = {
hash: hash,
implementation_name: null,
......
import type { AddressTabsCounters } from 'types/api/address';
export const base: AddressTabsCounters = {
internal_txs_count: 13,
logs_count: 51,
token_balances_count: 3,
token_transfers_count: 3,
transactions_count: 51,
validations_count: 42,
withdrawals_count: 11,
};
import type {
SmartContractQueryMethodReadError,
SmartContractQueryMethodReadSuccess,
SmartContractQueryMethodError,
SmartContractQueryMethodSuccess,
SmartContractReadMethod,
SmartContractWriteMethod,
} from 'types/api/contract';
......@@ -94,7 +94,7 @@ export const read: Array<SmartContractReadMethod> = [
},
];
export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
export const readResultSuccess: SmartContractQueryMethodSuccess = {
is_error: false,
result: {
names: [ 'amount' ],
......@@ -104,7 +104,7 @@ export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
},
};
export const readResultError: SmartContractQueryMethodReadError = {
export const readResultError: SmartContractQueryMethodError = {
is_error: true,
result: {
message: 'Some shit happened',
......
export const data = {
import type { OptimisticL2WithdrawalsResponse } from 'types/api/optimisticL2';
export const data: OptimisticL2WithdrawalsResponse = {
items: [
{
challenge_period_end: null,
......@@ -11,12 +13,12 @@ export const data = {
private_tags: [],
public_tags: [],
watchlist_names: [],
ens_domain_name: null,
},
l1_tx_hash: '0x1a235bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136dca684',
l2_timestamp: '2022-02-15T12:50:02.000000Z',
l2_tx_hash: '0x918cd8c5c24c17e06cd02b0379510c4ad56324bf153578fb9caaaa2fe4e7dc35',
msg_nonce: 396,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620172',
msg_nonce_version: 1,
status: 'Ready to prove',
},
......@@ -27,7 +29,6 @@ export const data = {
l2_timestamp: null,
l2_tx_hash: '0x2f117bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136def593',
msg_nonce: 391,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620167',
msg_nonce_version: 1,
status: 'Ready to prove',
},
......@@ -38,7 +39,6 @@ export const data = {
l2_timestamp: null,
l2_tx_hash: '0xe14b1f46838176702244a5343629bcecf728ca2d9881d47b4ce46e00c387d7e3',
msg_nonce: 390,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620166',
msg_nonce_version: 1,
status: 'Ready for relay',
},
......
import type { AddressMetadataInfo, AddressMetadataTag } from 'types/api/addressMetadata';
/* eslint-disable max-len */
import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
import { hash } from '../address/address';
export const nameTag1: AddressMetadataTag = {
slug: 'ethermineru',
name: 'Ethermine.ru',
export const nameTag: AddressMetadataTagApi = {
slug: 'quack-quack',
name: 'Quack quack',
tagType: 'name',
ordinal: 0,
ordinal: 99,
meta: null,
};
export const genericTag1: AddressMetadataTag = {
slug: 'ethermine.ru',
name: 'Ethermine.ru',
export const customNameTag: AddressMetadataTagApi = {
slug: 'unicorn-uproar',
name: 'Unicorn Uproar',
tagType: 'name',
ordinal: 777,
meta: {
tagUrl: 'https://example.com',
bgColor: 'linear-gradient(45deg, deeppink, deepskyblue)',
textColor: '#FFFFFF',
},
};
export const genericTag: AddressMetadataTagApi = {
slug: 'duck-owner',
name: 'duck owner 🦆',
tagType: 'generic',
ordinal: 0,
meta: null,
ordinal: 55,
meta: {
bgColor: 'rgba(255,243,12,90%)',
},
};
export const protocolTag1: AddressMetadataTag = {
export const infoTagWithLink: AddressMetadataTagApi = {
slug: 'goosegang',
name: 'GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG',
tagType: 'classifier',
ordinal: 11,
meta: {
tagUrl: 'https://example.com',
},
};
export const tagWithTooltip: AddressMetadataTagApi = {
slug: 'blockscout-heroes',
name: 'BlockscoutHeroes',
tagType: 'classifier',
ordinal: 42,
meta: {
tooltipDescription: 'The Blockscout team, EVM blockchain aficionados, illuminate Ethereum networks with unparalleled insight and prowess, leading the way in blockchain exploration! 🚀🔎',
tooltipIcon: 'https://localhost:3100/icon.svg',
tooltipTitle: 'Blockscout team member',
tooltipUrl: 'https://blockscout.com',
},
};
export const protocolTag: AddressMetadataTagApi = {
slug: 'aerodrome',
name: 'Aerodrome',
tagType: 'protocol',
ordinal: 0,
meta: null,
};
export const baseInfo: AddressMetadataInfo = {
addresses: {
[hash]: {
tags: [ nameTag1, genericTag1, protocolTag1 ],
reputation: null,
},
},
};
export const data = {
import type { AddressParam } from 'types/api/addressParams';
import type { WithdrawalsResponse } from 'types/api/withdrawals';
export const data: WithdrawalsResponse = {
items: [
{
amount: '192175000000000',
......@@ -10,7 +13,7 @@ export const data = {
is_contract: false,
is_verified: null,
name: null,
},
} as AddressParam,
timestamp: '2022-06-07T18:12:24.000000Z',
validator_index: 49622,
},
......@@ -24,7 +27,7 @@ export const data = {
is_contract: false,
is_verified: null,
name: null,
},
} as AddressParam,
timestamp: '2022-05-07T18:12:24.000000Z',
validator_index: 49621,
},
......@@ -38,7 +41,7 @@ export const data = {
is_contract: false,
is_verified: null,
name: null,
},
} as AddressParam,
timestamp: '2022-04-07T18:12:24.000000Z',
validator_index: 49620,
},
......
......@@ -10,6 +10,7 @@ const headers = require('./nextjs/headers');
const redirects = require('./nextjs/redirects');
const rewrites = require('./nextjs/rewrites');
/** @type {import('next').NextConfig} */
const moduleExports = {
transpilePackages: [
'react-syntax-highlighter',
......@@ -46,6 +47,14 @@ const moduleExports = {
productionBrowserSourceMaps: true,
experimental: {
instrumentationHook: true,
turbo: {
rules: {
'*.svg': {
loaders: [ '@svgr/webpack' ],
as: '*.js',
},
},
},
},
};
......
......@@ -18,6 +18,7 @@ import theme from 'theme';
export type Props = {
children: React.ReactNode;
withSocket?: boolean;
withWalletClient?: boolean;
appContext?: {
pageProps: PageProps;
};
......@@ -47,7 +48,20 @@ const wagmiConfig = createConfig({
},
});
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => {
const WalletClientProvider = ({ children, withWalletClient }: { children: React.ReactNode; withWalletClient?: boolean }) => {
if (withWalletClient) {
return (
<WagmiProvider config={ wagmiConfig }>
{ children }
</WagmiProvider>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
const TestApp = ({ children, withSocket, withWalletClient = true, appContext = defaultAppContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
......@@ -63,9 +77,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ app.socketPort }` : undefined }>
<AppContextProvider { ...appContext }>
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }>
<WalletClientProvider withWalletClient={ withWalletClient }>
{ children }
</WagmiProvider>
</WalletClientProvider>
</GrowthBookProvider>
</AppContextProvider>
</SocketProvider>
......
......@@ -16,6 +16,11 @@ const fixture: TestFixture<MockEnvsFixture, { page: Page }> = async({ page }, us
export default fixture;
export const ENVS_MAP: Record<string, Array<[string, string]>> = {
optimisticRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'optimistic' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
[ 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', 'https://localhost:3102' ],
],
shibariumRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'shibarium' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
......@@ -24,6 +29,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkEvm' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
],
zkSyncRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkSync' ],
[ '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"}]' ],
......@@ -37,4 +46,18 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
blockHiddenFields: [
[ 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', '["burnt_fees", "total_reward", "nonce"]' ],
],
stabilityEnvs: [
[ 'NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS', '["top_accounts"]' ],
[ 'NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS', '["value","fee_currency","gas_price","gas_fees","burnt_fees"]' ],
[ 'NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS', '["fee_per_gas"]' ],
],
beaconChain: [
[ 'NEXT_PUBLIC_HAS_BEACON_CHAIN', 'true' ],
],
txInterpretation: [
[ 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', 'blockscout' ],
],
noWalletClient: [
[ 'NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID', '' ],
],
};
......@@ -71,6 +71,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'total_supply', payload: { total_supply: number}): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_bytecode', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'fetched_bytecode', payload: { fetched_bytecode: string }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
......
......@@ -10,45 +10,3 @@ export const viewport = {
export const maskColor = '#4299E1'; // blue.400
export const adsBannerSelector = '.adsbyslise';
export const featureEnvs = {
beaconChain: [
{ name: 'NEXT_PUBLIC_HAS_BEACON_CHAIN', value: 'true' },
],
optimisticRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'optimistic' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
{ name: 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', value: 'https://localhost:3102' },
],
txInterpretation: [
{ name: 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', value: 'blockscout' },
],
zkEvmRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'zkEvm' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
],
zkSyncRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'zkSync' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
],
userOps: [
{ name: 'NEXT_PUBLIC_HAS_USER_OPS', value: 'true' },
],
validators: [
{ name: 'NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE', value: 'stability' },
],
};
export const viewsEnvs = {
block: {
hiddenFields: [
{ name: 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', value: '["burnt_fees", "total_reward", "nonce"]' },
],
},
};
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"]' },
{ name: 'NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS', value: '["fee_per_gas"]' },
];
......@@ -28,5 +28,5 @@ dotenv \
-v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \
-e $config_file \
-e $secrets_file \
-- bash -c './deploy/scripts/make_envs_script.sh && next dev -- -p $NEXT_PUBLIC_APP_PORT' |
-- bash -c './deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT --turbo' |
pino-pretty
\ No newline at end of file
......@@ -7,6 +7,7 @@ export interface AddressMetadataInfo {
export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol';
// Response model from Metadata microservice API
export interface AddressMetadataTag {
slug: string;
name: string;
......@@ -14,3 +15,20 @@ export interface AddressMetadataTag {
ordinal: number;
meta: string | null;
}
// Response model from Blockscout API with parsed meta field
export interface AddressMetadataTagApi extends Omit<AddressMetadataTag, 'meta'> {
meta: {
textColor?: string;
bgColor?: string;
tagUrl?: string;
tooltipIcon?: string;
tooltipTitle?: string;
tooltipDescription?: string;
tooltipUrl?: string;
actionURL?: string;
appID?: string;
logoURL?: string;
text?: string;
} | null;
}
import type { AddressMetadataTagApi } from './addressMetadata';
export interface AddressTag {
label: string;
display_name: string;
......@@ -22,6 +24,10 @@ export type AddressParamBasic = {
is_contract: boolean;
is_verified: boolean | null;
ens_domain_name: string | null;
metadata?: {
reputation: number | null;
tags: Array<AddressMetadataTagApi>;
} | null;
}
export type AddressParam = UserTags & AddressParamBasic;
import type { Abi, AbiType } from 'abitype';
import type { Abi, AbiType, AbiFallback, AbiFunction, AbiReceive } from 'abitype';
export type SmartContractMethodArgType = AbiType;
export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
......@@ -78,49 +78,19 @@ export interface SmartContractExternalLibrary {
name: string;
}
export interface SmartContractMethodBase {
inputs: Array<SmartContractMethodInput>;
outputs?: Array<SmartContractMethodOutput>;
constant: boolean;
name: string;
stateMutability: SmartContractMethodStateMutability;
type: 'function';
payable: boolean;
error?: string;
export type SmartContractMethodOutputValue = string | boolean | object;
export type SmartContractMethodOutput = AbiFunction['outputs'][number] & { value?: SmartContractMethodOutputValue };
export type SmartContractMethodBase = Omit<AbiFunction, 'outputs'> & {
method_id: string;
}
outputs: Array<SmartContractMethodOutput>;
constant?: boolean;
error?: string;
};
export type SmartContractReadMethod = SmartContractMethodBase;
export interface SmartContractWriteFallback {
payable?: true;
stateMutability: 'payable';
type: 'fallback';
}
export interface SmartContractWriteReceive {
payable?: true;
stateMutability: 'payable';
type: 'receive';
}
export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWriteFallback | SmartContractWriteReceive;
export type SmartContractWriteMethod = SmartContractMethodBase | AbiFallback | AbiReceive;
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput {
internalType?: string; // there could be any string, e.g "enum MyEnum"
name: string;
type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>;
fieldType?: 'native_coin';
}
export interface SmartContractMethodOutput extends SmartContractMethodInput {
value?: string | boolean | object;
}
export interface SmartContractQueryMethodReadSuccess {
export interface SmartContractQueryMethodSuccess {
is_error: false;
result: {
names: Array<string | [ string, Array<string> ]>;
......@@ -131,7 +101,7 @@ export interface SmartContractQueryMethodReadSuccess {
};
}
export interface SmartContractQueryMethodReadError {
export interface SmartContractQueryMethodError {
is_error: true;
result: {
code: number;
......@@ -147,7 +117,7 @@ export interface SmartContractQueryMethodReadError {
};
}
export type SmartContractQueryMethodRead = SmartContractQueryMethodReadSuccess | SmartContractQueryMethodReadError;
export type SmartContractQueryMethod = SmartContractQueryMethodSuccess | SmartContractQueryMethodError;
// VERIFICATION
......
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
export interface AddressMetadataInfoFormatted {
addresses: Record<string, {
......@@ -7,21 +7,4 @@ export interface AddressMetadataInfoFormatted {
}>;
}
export interface AddressMetadataTagFormatted {
slug: string;
name: string;
tagType: AddressMetadataTagType;
ordinal: number;
meta: {
textColor?: string;
bgColor?: string;
actionURL?: string;
appID?: string;
logoURL?: string;
text?: string;
tooltipIcon?: string;
tooltipTitle?: string;
tooltipDescription?: string;
tooltipUrl?: string;
} | null;
}
export type AddressMetadataTagFormatted = AddressMetadataTagApi;
......@@ -5,6 +5,8 @@ export const BLOCK_FIELDS_IDS = [
'total_reward',
'nonce',
'miner',
'L1_status',
'batch',
] as const;
export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>;
......@@ -7,6 +7,8 @@ export const TX_FIELDS_IDS = [
'tx_fee',
'gas_fees',
'burnt_fees',
'L1_status',
'batch',
] as const;
export type TxFieldsId = ArrayElement<typeof TX_FIELDS_IDS>;
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as contractInfoMock from 'mocks/contract/info';
import * as contractMethodsMock from 'mocks/contract/methods';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib';
import AddressContract from './AddressContract.pwstory';
const hash = addressMock.contract.hash;
test.beforeEach(async({ mockApiResponse }) => {
await mockApiResponse('address', addressMock.contract, { pathParams: { hash } });
await mockApiResponse('contract', contractInfoMock.verified, { pathParams: { hash } });
await mockApiResponse('contract_methods_read', contractMethodsMock.read, { pathParams: { hash }, queryParams: { is_custom_abi: 'false' } });
await mockApiResponse('contract_methods_write', contractMethodsMock.write, { pathParams: { hash }, queryParams: { is_custom_abi: 'false' } });
});
test.describe('ABI functionality', () => {
test('read', async({ render, createSocket }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'read_contract' },
},
};
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('FLASHLOAN_PREMIUM_TOTAL').click();
await expect(component.getByRole('button', { name: 'Read' })).toBeVisible();
});
test('read, no wallet client', async({ render, createSocket, mockEnvs }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'read_contract' },
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('FLASHLOAN_PREMIUM_TOTAL').click();
await expect(component.getByRole('button', { name: 'Read' })).toBeVisible();
});
test('write', async({ render, createSocket }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'write_contract' },
},
};
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeEnabled();
await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeEnabled();
});
test('write, no wallet client', async({ render, createSocket, mockEnvs }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'write_contract' },
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeDisabled();
await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeDisabled();
});
});
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressContract from './AddressContract';
const AddressContractPwStory = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const addressQuery = useApiQuery('address', { pathParams: { hash } });
const { tabs } = useContractTabs(addressQuery.data, false);
return <AddressContract tabs={ tabs } shouldRender={ true } isLoading={ false }/>;
};
export default AddressContractPwStory;
......@@ -3,7 +3,6 @@ import React from 'react';
import type { RoutedSubTab } from 'ui/shared/Tabs/types';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props {
tabs: Array<RoutedSubTab>;
......@@ -16,21 +15,12 @@ const TAB_LIST_PROPS = {
};
const AddressContract = ({ tabs, isLoading, shouldRender }: Props) => {
const fallback = React.useCallback(() => {
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 } isLoading={ isLoading }/>
);
}, [ isLoading, tabs ]);
if (!shouldRender) {
return null;
}
return (
<Web3ModalProvider fallback={ fallback }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
</Web3ModalProvider>
);
};
......
import { Accordion, Box, Flex, Link } from '@chakra-ui/react';
import _range from 'lodash/range';
import React from 'react';
import { scroller } from 'react-scroll';
import type { SmartContractMethod } from 'types/api/contract';
import type { MethodType, ContractAbi as TContractAbi } from './types';
import ContractMethodsAccordionItem from './ContractMethodsAccordionItem';
import ContractAbiItem from './ContractAbiItem';
import useFormSubmit from './useFormSubmit';
import useScrollToMethod from './useScrollToMethod';
interface Props<T extends SmartContractMethod> {
data: Array<T>;
addressHash?: string;
renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
interface Props {
data: TContractAbi;
addressHash: string;
tab: string;
methodType: MethodType;
}
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent, tab }: Props<T>) => {
const ContractAbi = ({ data, addressHash, tab, methodType }: Props) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []);
const [ id, setId ] = React.useState(0);
React.useEffect(() => {
const hash = window.location.hash.replace('#', '');
useScrollToMethod(data, setExpandedSections);
if (!hash) {
return;
}
const index = data.findIndex((item) => 'method_id' in item && item.method_id === hash);
if (index > -1) {
scroller.scrollTo(`method_${ hash }`, {
duration: 500,
smooth: true,
offset: -100,
});
setExpandedSections([ index ]);
}
}, [ data ]);
const handleFormSubmit = useFormSubmit({ addressHash, tab });
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue);
......@@ -73,14 +60,15 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
</Flex>
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => (
<ContractMethodsAccordionItem
<ContractAbiItem
key={ index }
data={ item }
id={ id }
index={ index }
addressHash={ addressHash }
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
tab={ tab }
onSubmit={ handleFormSubmit }
methodType={ methodType }
/>
)) }
</Accordion>
......@@ -88,4 +76,4 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
);
};
export default React.memo(ContractMethodsAccordion) as typeof ContractMethodsAccordion;
export default React.memo(ContractAbi);
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react';
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box, Flex, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import { Element } from 'react-scroll';
import type { SmartContractMethod } from 'types/api/contract';
import type { FormSubmitHandler, MethodType, ContractAbiItem as TContractAbiItem } from './types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg';
interface Props<T extends SmartContractMethod> {
data: T;
import ContractAbiItemConstant from './ContractAbiItemConstant';
import ContractMethodForm from './form/ContractMethodForm';
import { getElementName } from './useScrollToMethod';
interface Props {
data: TContractAbiItem;
index: number;
id: number;
addressHash?: string;
renderContent: (item: T, index: number, id: number) => React.ReactNode;
addressHash: string;
tab: string;
onSubmit: FormSubmitHandler;
methodType: MethodType;
}
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent, tab }: Props<T>) => {
const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodType }: Props) => {
const url = React.useMemo(() => {
if (!('method_id' in data)) {
return '';
......@@ -43,11 +50,40 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
onCopy();
}, [ onCopy ]);
const handleCopyMethodIdClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
const content = (() => {
if ('error' in data && data.error) {
return <Alert status="error" fontSize="sm" wordBreak="break-word">{ data.error }</Alert>;
}
const hasConstantOutputs = 'outputs' in data && data.outputs.some(({ value }) => value !== undefined && value !== null);
if (hasConstantOutputs) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ data.outputs.map((output, index) => <ContractAbiItemConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodForm
key={ id + '_' + index }
data={ data }
onSubmit={ onSubmit }
methodType={ methodType }
/>
);
})();
return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ isExpanded }) => (
<>
<Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }>
<Element as="h2" name={ 'method_id' in data ? getElementName(data.method_id) : '' }>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
{ 'method_id' in data && (
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
......@@ -85,11 +121,17 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
the contract cannot receive Ether through regular transactions and throws an exception.`
}/>
) }
{ 'method_id' in data && (
<>
<Tag>{ data.method_id }</Tag>
<CopyToClipboard text={ `${ data.name } (${ data.method_id })` } onClick={ handleCopyMethodIdClick }/>
</>
) }
<AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
</AccordionButton>
</Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) }
{ content }
</AccordionPanel>
</>
) }
......@@ -97,4 +139,4 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
);
};
export default React.memo(ContractMethodsAccordionItem);
export default React.memo(ContractAbiItem);
......@@ -4,12 +4,14 @@ import type { ChangeEvent } from 'react';
import React from 'react';
import { getAddress } from 'viem';
import type { SmartContractMethodOutput } from 'types/api/contract';
import type { ContractAbiItemOutput } from './types';
import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { matchInt } from './form/utils';
function castValueToString(value: number | string | boolean | object | bigint | undefined): string {
switch (typeof value) {
case 'string':
......@@ -28,13 +30,15 @@ function castValueToString(value: number | string | boolean | object | bigint |
}
interface Props {
data: SmartContractMethodOutput;
data: ContractAbiItemOutput;
}
const ContractMethodStatic = ({ data }: Props) => {
const ContractAbiItemConstant = ({ data }: Props) => {
const [ value, setValue ] = React.useState<string>(castValueToString(data.value));
const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase());
const intMatch = matchInt(data.type);
const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const initialValue = castValueToString(data.value);
......@@ -63,9 +67,9 @@ const ContractMethodStatic = ({ data }: Props) => {
return (
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }>
{ content }
{ (data.type.includes('int256') || data.type.includes('int128')) && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
{ Number(intMatch?.power) >= 128 && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
</Flex>
);
};
export default ContractMethodStatic;
export default ContractAbiItemConstant;
......@@ -3,18 +3,18 @@ import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { NumericFormat } from 'react-number-format';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useArgTypeMatchInt from './useArgTypeMatchInt';
import useFormatFieldValue from './useFormatFieldValue';
import useValidateField from './useValidateField';
import { matchInt } from './utils';
interface Props {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
hideLabel?: boolean;
path: string;
className?: string;
......@@ -28,7 +28,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const isNativeCoin = data.fieldType === 'native_coin';
const isOptional = isNativeCoin;
const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type });
const argTypeMatchInt = React.useMemo(() => matchInt(data.type), [ data.type ]);
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
......
......@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput, SmartContractMethodArgType } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import ContractMethodArrayButton from './ContractMethodArrayButton';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
......@@ -10,21 +10,35 @@ import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import { getFieldLabel } from './utils';
import { getFieldLabel, matchArray, transformDataForArrayItem } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
level: number;
basePath: string;
isDisabled: boolean;
isArrayElement?: boolean;
size?: number;
}
const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRemoveClick, index: parentIndex, isDisabled }: Props) => {
const ContractMethodFieldInputArray = ({
data,
level,
basePath,
onAddClick,
onRemoveClick,
index: parentIndex,
isDisabled,
isArrayElement,
}: Props) => {
const { formState: { errors } } = useFormContext();
const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
const [ registeredIndices, setRegisteredIndices ] = React.useState([ 0 ]);
const arrayMatch = matchArray(data.type);
const hasFixedSize = arrayMatch !== null && arrayMatch.size !== Infinity;
const [ registeredIndices, setRegisteredIndices ] = React.useState(hasFixedSize ? Array(arrayMatch.size).fill(0).map((_, i) => i) : [ 0 ]);
const handleAddButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
......@@ -39,63 +53,48 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
}
}, [ ]);
const getItemData = (index: number) => {
const childrenType = data.type.slice(0, -2) as SmartContractMethodArgType;
const childrenInternalType = data.internalType?.slice(0, parentIndex !== undefined ? -4 : -2).replaceAll('struct ', '');
const namePostfix = childrenInternalType ? ' ' + childrenInternalType : '';
const nameParentIndex = parentIndex !== undefined ? `${ parentIndex + 1 }.` : '';
const nameIndex = index + 1;
return {
...data,
type: childrenType,
name: `#${ nameParentIndex + nameIndex }${ namePostfix }`,
};
};
const isNestedArray = data.type.includes('[][]');
if (arrayMatch?.isNested) {
return (
<>
{
registeredIndices.map((registeredIndex, index) => {
const itemData = transformDataForArrayItem(data, index);
const itemBasePath = `${ basePath }:${ registeredIndex }`;
const itemIsInvalid = fieldsWithErrors.some((field) => field.startsWith(itemBasePath));
if (isNestedArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
key={ registeredIndex }
level={ level + 1 }
label={ getFieldLabel(itemData) }
isInvalid={ itemIsInvalid }
onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputArray
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
basePath={ itemBasePath }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
isArrayElement
/>
);
}) }
</ContractMethodFieldAccordion>
);
})
}
</>
);
}
const isTupleArray = data.type.includes('tuple');
const isTupleArray = arrayMatch?.itemType.includes('tuple');
if (isTupleArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
onAddClick={ onAddClick }
onRemoveClick={ onRemoveClick }
index={ parentIndex }
isInvalid={ isInvalid }
>
const content = (
<>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
const itemData = transformDataForArrayItem(data, index);
return (
<ContractMethodFieldInputTuple
......@@ -103,13 +102,30 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</>
);
if (isArrayElement) {
return content;
}
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
onAddClick={ onAddClick }
onRemoveClick={ onRemoveClick }
index={ parentIndex }
isInvalid={ isInvalid }
>
{ content }
</ContractMethodFieldAccordion>
);
}
......@@ -117,10 +133,10 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
// primitive value array
return (
<Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px">
<ContractMethodFieldLabel data={ data } level={ level }/>
{ !isArrayElement && <ContractMethodFieldLabel data={ data } level={ level }/> }
<Flex flexDir="column" rowGap={ 1 } w="100%">
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
const itemData = transformDataForArrayItem(data, index);
return (
<Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }>
......@@ -132,9 +148,9 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
px={ 0 }
isDisabled={ isDisabled }
/>
{ registeredIndices.length > 1 &&
{ !hasFixedSize && registeredIndices.length > 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> }
{ index === registeredIndices.length - 1 &&
{ !hasFixedSize && index === registeredIndices.length - 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> }
</Flex>
);
......
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import { ARRAY_REGEXP, getFieldLabel } from './utils';
import { getFieldLabel, matchArray } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
basePath: string;
level: number;
isDisabled: boolean;
......@@ -21,6 +21,10 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
if (!('components' in data)) {
return null;
}
return (
<ContractMethodFieldAccordion
{ ...accordionProps }
......@@ -29,7 +33,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
isInvalid={ isInvalid }
>
{ data.components?.map((component, index) => {
if (component.components && component.type === 'tuple') {
if ('components' in component && component.type === 'tuple') {
return (
<ContractMethodFieldInputTuple
key={ index }
......@@ -41,15 +45,14 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
);
}
const arrayMatch = component.type.match(ARRAY_REGEXP);
const arrayMatch = matchArray(component.type);
if (arrayMatch) {
const [ , itemType ] = arrayMatch;
return (
<ContractMethodFieldInputArray
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ itemType === 'tuple' ? level + 1 : level }
level={ arrayMatch.itemType === 'tuple' ? level + 1 : level }
isDisabled={ isDisabled }
/>
);
......
import { Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import { getFieldLabel } from './utils';
interface Props {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
isOptional?: boolean;
level: number;
}
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { SmartContractWriteMethod } from 'types/api/contract';
import type { ContractAbiItem } from '../types';
import TestApp from 'playwright/TestApp';
import ContractMethodForm from './ContractMethodForm';
const onSubmit = () => Promise.resolve({ hash: '0x0000' as `0x${ string }` });
const resultComponent = () => null;
const onSubmit = () => Promise.resolve({ source: 'wallet_client' as const, result: { hash: '0x0000' as `0x${ string }` } });
const data: SmartContractWriteMethod = {
const data: ContractAbiItem = {
inputs: [
// TUPLE
{
......@@ -53,6 +52,13 @@ const data: SmartContractWriteMethod = {
type: 'tuple[][]',
},
// TOP LEVEL NESTED ARRAY
{
internalType: 'int256[2][][3]',
name: 'ParentArray',
type: 'int256[2][][3]',
},
// LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' },
......@@ -95,10 +101,9 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<ContractMethodForm<SmartContractWriteMethod>
<ContractMethodForm
data={ data }
onSubmit={ onSubmit }
resultComponent={ resultComponent }
methodType="write"
/>
</TestApp>,
......@@ -125,9 +130,13 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
await component.getByText('struct FulfillmentComponent[][]').click();
await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('#1 FulfillmentComponent[]').click();
await component.getByText('#1.1 FulfillmentComponent').click();
await component.getByLabel('#1 FulfillmentComponent[] (tuple[])').getByText('#1 FulfillmentComponent (tuple)').click();
await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('ParentArray (int256[2][][3])').click();
await component.getByText('#1 int256[2][] (int256[2][])').click();
await component.getByLabel('#1 int256[2][] (int256[2][])').getByText('#1 int256[2] (int256[2])').click();
// submit form
await component.getByRole('button', { name: 'Write' }).click();
......
import { Box, Button, Flex, chakra } from '@chakra-ui/react';
import { Box, Button, Flex, Tooltip, chakra } from '@chakra-ui/react';
import _mapValues from 'lodash/mapValues';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { AbiFunction } from 'viem';
import type { ContractMethodCallResult } from '../types';
import type { ResultComponentProps } from './types';
import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract';
import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, MethodType, ContractAbiItem } from '../types';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFormOutputs from './ContractMethodFormOutputs';
import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils';
import ContractMethodOutputs from './ContractMethodOutputs';
import ContractMethodResult from './ContractMethodResult';
import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils';
import type { ContractMethodFormFields } from './utils';
interface Props<T extends SmartContractMethod> {
data: T;
onSubmit: (data: T, args: Array<unknown>) => Promise<ContractMethodCallResult<T>>;
resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null;
methodType: 'read' | 'write';
interface Props {
data: ContractAbiItem;
onSubmit: FormSubmitHandler;
methodType: MethodType;
}
const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props<T>) => {
const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>();
const [ result, setResult ] = React.useState<FormSubmitResult>();
const [ isLoading, setLoading ] = React.useState(false);
const [ callStrategy, setCallStrategy ] = React.useState<MethodCallStrategy>();
const callStrategyRef = React.useRef(callStrategy);
const formApi = useForm<ContractMethodFormFields>({
mode: 'all',
shouldUnregister: true,
});
const handleButtonClick = React.useCallback((event: React.MouseEvent) => {
const callStrategy = event?.currentTarget.getAttribute('data-call-strategy');
setCallStrategy(callStrategy as MethodCallStrategy);
callStrategyRef.current = callStrategy as MethodCallStrategy;
}, []);
const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
// The API used for reading from contracts expects all values to be strings.
const formattedData = methodType === 'read' ?
const formattedData = callStrategyRef.current === 'api' ?
_mapValues(formData, (value) => value !== undefined ? String(value) : undefined) :
formData;
const args = transformFormDataToMethodArgs(formattedData);
......@@ -45,12 +53,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
setResult(undefined);
setLoading(true);
onSubmit(data, args)
onSubmit(data, args, callStrategyRef.current)
.then((result) => {
setResult(result);
})
.catch((error) => {
setResult(error?.error || error?.data || (error?.reason && { message: error.reason }) || error);
setResult({
source: callStrategyRef.current ?? 'wallet_client',
result: error?.error || error?.data || (error?.reason && { message: error.reason }) || error,
});
setLoading(false);
})
.finally(() => {
......@@ -69,9 +80,9 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
result && setResult(undefined);
}, [ result ]);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => {
const inputs: AbiFunction['inputs'] = React.useMemo(() => {
return [
...('inputs' in data ? data.inputs : []),
...('inputs' in data && data.inputs ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const,
......@@ -83,6 +94,28 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
const outputs = 'outputs' in data && data.outputs ? data.outputs : [];
const callStrategies = (() => {
switch (methodType) {
case 'read': {
return { primary: 'api', secondary: undefined };
}
case 'write': {
return {
primary: config.features.blockchainInteraction.isEnabled ? 'wallet_client' : undefined,
secondary: 'outputs' in data && Boolean(data.outputs?.length) ? 'api' : undefined,
};
}
default: {
return { primary: undefined, secondary: undefined };
}
}
})();
// eslint-disable-next-line max-len
const noWalletClientText = 'Blockchain interaction is not available at the moment since WalletConnect is not configured for this application. Please contact the service maintainer to make necessary changes in the service configuration.';
return (
<Box>
<FormProvider { ...formApi }>
......@@ -93,20 +126,65 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
>
<Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}>
{ inputs.map((input, index) => {
if (input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
const props = {
data: input,
basePath: `${ index }`,
isDisabled: isLoading,
level: 0,
};
if ('components' in input && input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } { ...props }/>;
}
const arrayMatch = input.type.match(ARRAY_REGEXP);
const arrayMatch = matchArray(input.type);
if (arrayMatch) {
return <ContractMethodFieldInputArray key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
if (arrayMatch.isNested) {
const fieldsWithErrors = Object.keys(formApi.formState.errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(index + ':'));
return (
<ContractMethodFieldAccordion
key={ index }
level={ 0 }
label={ getFieldLabel(input) }
isInvalid={ isInvalid }
>
<ContractMethodFieldInputArray { ...props }/>
</ContractMethodFieldAccordion>
);
}
return <ContractMethodFieldInputArray key={ index } { ...props }/>;
}
return <ContractMethodFieldInput key={ index } data={ input } path={ `${ index }` } isDisabled={ isLoading } level={ 0 }/>;
return <ContractMethodFieldInput key={ index } { ...props } path={ `${ index }` }/>;
}) }
</Flex>
{ callStrategies.secondary && (
<Button
isLoading={ callStrategy === callStrategies.secondary && isLoading }
isDisabled={ isLoading }
onClick={ handleButtonClick }
loadingText="Simulate"
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
mr={ 3 }
type="submit"
data-call-strategy={ callStrategies.secondary }
>
Simulate
</Button>
) }
<Tooltip label={ !callStrategies.primary ? noWalletClientText : undefined } maxW="300px">
<Button
isLoading={ isLoading }
isLoading={ callStrategy === callStrategies.primary && isLoading }
isDisabled={ isLoading || !callStrategies.primary }
onClick={ handleButtonClick }
loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline"
size="sm"
......@@ -114,13 +192,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
width="min-content"
px={ 4 }
type="submit"
data-call-strategy={ callStrategies.primary }
>
{ methodType === 'write' ? 'Write' : 'Read' }
</Button>
</Tooltip>
</chakra.form>
</FormProvider>
{ methodType === 'read' && <ContractMethodFormOutputs data={ outputs }/> }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
{ 'outputs' in data && Boolean(data.outputs?.length) && <ContractMethodOutputs data={ outputs }/> }
{ result && <ContractMethodResult abiItem={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box>
);
};
......
import { Flex, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodOutput } from 'types/api/contract';
import type { AbiFunction } from 'viem';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: Array<SmartContractMethodOutput>;
data: AbiFunction['outputs'];
}
const ContractMethodFormOutputs = ({ data }: Props) => {
const ContractMethodOutputs = ({ data }: Props) => {
if (data.length === 0) {
return null;
}
......@@ -32,4 +31,4 @@ const ContractMethodFormOutputs = ({ data }: Props) => {
);
};
export default React.memo(ContractMethodFormOutputs);
export default React.memo(ContractMethodOutputs);
import React from 'react';
import type { FormSubmitResult, ContractAbiItem } from '../types';
import ContractMethodResultApi from './ContractMethodResultApi';
import ContractMethodResultWalletClient from './ContractMethodResultWalletClient';
interface Props {
abiItem: ContractAbiItem;
result: FormSubmitResult;
onSettle: () => void;
}
const ContractMethodResult = ({ result, abiItem, onSettle }: Props) => {
switch (result.source) {
case 'api':
return <ContractMethodResultApi item={ abiItem } result={ result.result } onSettle={ onSettle }/>;
case 'wallet_client':
return <ContractMethodResultWalletClient result={ result.result } onSettle={ onSettle }/>;
default: {
return null;
}
}
};
export default React.memo(ContractMethodResult);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { ContractMethodReadResult } from './types';
import type { FormSubmitResultApi } from '../types';
import * as contractMethodsMock from 'mocks/contract/methods';
import TestApp from 'playwright/TestApp';
import { test, expect } from 'playwright/lib';
import ContractReadResult from './ContractReadResult';
import ContractMethodResultApi from './ContractMethodResultApi';
const item = contractMethodsMock.read[0];
const onSettle = () => Promise.resolve();
test.use({ viewport: { width: 500, height: 500 } });
test('default error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('default error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
error: 'I am an error',
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('error with code', async({ mount }) => {
const result: ContractMethodReadResult = {
test('error with code', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
message: 'I am an error',
code: -32017,
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('raw error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('raw error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72',
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('complex error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('complex error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
method_call: 'SomeCustomError(address addr, uint256 balance)',
......@@ -74,34 +61,26 @@ test('complex error', async({ mount }) => {
],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('success', async({ mount }) => {
const result: ContractMethodReadResult = {
test('success', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: false,
result: {
names: [ 'address' ],
output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('complex success', async({ mount }) => {
const result: ContractMethodReadResult = {
test('complex success', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: false,
result: {
names: [
......@@ -122,11 +101,7 @@ test('complex success', async({ mount }) => {
],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractAbiItem, FormSubmitResultApi } from '../types';
import hexToUtf8 from 'lib/hexToUtf8';
import ContractMethodResultApiError from './ContractMethodResultApiError';
import ContractMethodResultApiItem from './ContractMethodResultApiItem';
interface Props {
item: ContractAbiItem;
result: FormSubmitResultApi['result'];
onSettle: () => void;
}
const ContractMethodResultApi = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractMethodResultApiError>{ result.statusText }</ContractMethodResultApiError>;
}
if (result instanceof Error) {
return <ContractMethodResultApiError>{ result.message }</ContractMethodResultApiError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractMethodResultApiError>{ result.result.error }</ContractMethodResultApiError>;
}
if ('message' in result.result) {
return <ContractMethodResultApiError>[{ result.result.code }] { result.result.message }</ContractMethodResultApiError>;
}
if ('raw' in result.result) {
return <ContractMethodResultApiError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractMethodResultApiError>;
}
if ('method_id' in result.result) {
return <ContractMethodResultApiError>{ JSON.stringify(result.result, undefined, 2) }</ContractMethodResultApiError>;
}
return <ContractMethodResultApiError>Something went wrong.</ContractMethodResultApiError>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p>
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map((output, index) => <ContractMethodResultApiItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractMethodResultApi);
import { Alert } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
}
const ContractMethodResultApiError = ({ children }: Props) => {
return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children }
</Alert>
);
};
export default React.memo(ContractMethodResultApiError);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractQueryMethodSuccess } from 'types/api/contract';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
interface Props {
output: SmartContractQueryMethodSuccess['result']['output'][0];
name: SmartContractQueryMethodSuccess['result']['names'][0];
}
const ContractMethodResultApiItem = ({ output, name }: Props) => {
if (Array.isArray(name)) {
const [ structName, argNames ] = name;
const argTypes = output.type.match(TUPLE_TYPE_REGEX)?.[1].split(',');
return (
<>
<p>
<chakra.span fontWeight={ 500 }> { structName }</chakra.span>
<span> ({ output.type }) :</span>
</p>
{ argNames.map((argName, argIndex) => {
return (
<p key={ argName }>
<chakra.span fontWeight={ 500 }> { argName }</chakra.span>
<span>{ argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) }</span>
</p>
);
}) }
</>
);
}
return (
<p>
<span> </span>
{ name && <chakra.span fontWeight={ 500 }>{ name } </chakra.span> }
<span>({ output.type }) : { String(output.value) }</span>
</p>
);
};
export default React.memo(ContractMethodResultApiItem);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import { test, expect } from 'playwright/lib';
import ContractWriteResultDumb from './ContractWriteResultDumb';
import type { PropsDumb } from './ContractMethodResultWalletClient';
import { ContractMethodResultWalletClientDumb } from './ContractMethodResultWalletClient';
test('loading', async({ mount }) => {
test('loading', async({ render }) => {
const props = {
txInfo: {
status: 'pending' as const,
error: null,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('success', async({ mount }) => {
test('success', async({ render }) => {
const props = {
txInfo: {
status: 'success' as const,
error: null,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('error +@mobile', async({ mount }) => {
test('error +@mobile', async({ render }) => {
const props = {
txInfo: {
status: 'error' as const,
......@@ -55,39 +45,29 @@ test('error +@mobile', async({ mount }) => {
// eslint-disable-next-line max-len
message: 'missing revert data in call exception; Transaction reverted without a reason string [ See: https://links.ethers.org/v5-errors-CALL_EXCEPTION ]',
} as Error,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('error in result', async({ mount }) => {
test('error in result', async({ render }) => {
const props = {
txInfo: {
status: 'idle' as const,
error: null,
},
} as unknown as PropsDumb['txInfo'],
result: {
message: 'wallet is not connected',
} as Error,
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, Spinner } from '@chakra-ui/react';
import { chakra, Spinner, Box } from '@chakra-ui/react';
import React from 'react';
import type { UseWaitForTransactionReceiptReturnType } from 'wagmi';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ContractMethodWriteResult } from './types';
import type { FormSubmitResultWalletClient } from '../types';
import { route } from 'nextjs-routes';
import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
result: ContractMethodWriteResult;
result: FormSubmitResultWalletClient['result'];
onSettle: () => void;
txInfo: {
status: 'loading' | 'success' | 'error' | 'idle' | 'pending';
error: Error | null;
};
}
const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
const ContractMethodResultWalletClient = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractMethodResultWalletClientDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export interface PropsDumb {
result: FormSubmitResultWalletClient['result'];
onSettle: () => void;
txInfo: UseWaitForTransactionReceiptReturnType;
}
export const ContractMethodResultWalletClientDumb = ({ result, onSettle, txInfo }: PropsDumb) => {
const txHash = result && 'hash' in result ? result.hash : undefined;
React.useEffect(() => {
......@@ -93,4 +106,4 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
);
};
export default React.memo(ContractWriteResultDumb);
export default React.memo(ContractMethodResultWalletClient);
import React from 'react';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import type { MatchInt } from './utils';
interface Params {
argType: SmartContractMethodArgType;
argType: string;
argTypeMatchInt: MatchInt | null;
}
......
import React from 'react';
import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import type { MatchInt } from './utils';
import { BYTES_REGEXP } from './utils';
interface Params {
argType: SmartContractMethodArgType;
argType: string;
argTypeMatchInt: MatchInt | null;
isOptional: boolean;
}
......
import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
export type ContractMethodFormFields = Record<string, string | boolean | undefined>;
......@@ -10,6 +10,62 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i;
export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
export interface MatchArray {
itemType: string;
size: number;
isNested: boolean;
}
export const matchArray = (argType: string): MatchArray | null => {
const match = argType.match(ARRAY_REGEXP);
if (!match) {
return null;
}
const [ , itemType, size ] = match;
const isNested = Boolean(matchArray(itemType));
return {
itemType,
size: size ? Number(size) : Infinity,
isNested,
};
};
export interface MatchInt {
isUnsigned: boolean;
power: string;
min: bigint;
max: bigint;
}
export const matchInt = (argType: string): MatchInt | null => {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
}
const [ , isUnsigned, power = '256' ] = match;
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
return { isUnsigned: Boolean(isUnsigned), power, min, max };
};
export const transformDataForArrayItem = (data: ContractAbiItemInput, index: number): ContractAbiItemInput => {
const arrayMatchType = matchArray(data.type);
const arrayMatchInternalType = data.internalType ? matchArray(data.internalType) : null;
const childrenInternalType = arrayMatchInternalType?.itemType.replaceAll('struct ', '');
const postfix = childrenInternalType ? ' ' + childrenInternalType : '';
return {
...data,
type: arrayMatchType?.itemType || data.type,
internalType: childrenInternalType,
name: `#${ index + 1 }${ postfix }`,
};
};
export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
const maxUnsigned = BigInt(2 ** power);
const max = isUnsigned ? maxUnsigned - BigInt(1) : maxUnsigned / BigInt(2) - BigInt(1);
......@@ -41,7 +97,7 @@ function filterOurEmptyItems(array: Array<unknown>): Array<unknown> {
.filter((item) => item !== undefined);
}
export function getFieldLabel(input: SmartContractMethodInput, isRequired?: boolean) {
export function getFieldLabel(input: ContractAbiItemInput, isRequired?: boolean) {
const name = input.name || input.internalType || '<unnamed argument>';
return `${ name } (${ input.type })${ isRequired ? '*' : '' }`;
}
import type { AbiFunction } from 'abitype';
import type { SmartContractMethod, SmartContractMethodOutput, SmartContractQueryMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
export type ContractAbiItemInput = AbiFunction['inputs'][number] & { fieldType?: 'native_coin' };
export type ContractAbiItemOutput = SmartContractMethodOutput;
export type ContractAbiItem = SmartContractMethod;
export type ContractAbi = Array<ContractAbiItem>;
export type MethodType = 'read' | 'write';
export type MethodCallStrategy = 'api' | 'wallet_client';
export interface FormSubmitResultApi {
source: 'api';
result: SmartContractQueryMethod | ResourceError | Error;
}
export interface FormSubmitResultWalletClient {
source: 'wallet_client';
result: Error | { hash: `0x${ string }` | undefined } | undefined;
}
export type FormSubmitResult = FormSubmitResultApi | FormSubmitResultWalletClient;
export type FormSubmitHandler = (item: ContractAbiItem, args: Array<unknown>, submitType: MethodCallStrategy | undefined) => Promise<FormSubmitResult>;
import React from 'react';
import type { FormSubmitResult } from './types';
import type { SmartContractQueryMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useAccount from 'lib/web3/useAccount';
interface Params {
methodId: string;
args: Array<unknown>;
isProxy: boolean;
isCustomAbi: boolean;
addressHash: string;
}
export default function useCallMethodApi(): (params: Params) => Promise<FormSubmitResult> {
const apiFetch = useApiFetch();
const { address } = useAccount();
return React.useCallback(async({ addressHash, isCustomAbi, isProxy, args, methodId }) => {
try {
const response = await apiFetch<'contract_method_query', SmartContractQueryMethod>('contract_method_query', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
fetchParams: {
method: 'POST',
body: {
args,
method_id: methodId,
contract_type: isProxy ? 'proxy' : 'regular',
from: address,
},
},
});
return {
source: 'api',
result: response,
};
} catch (error) {
return {
source: 'api',
result: error as (Error | ResourceError),
};
}
}, [ address, apiFetch ]);
}
import React from 'react';
import type { Abi } from 'viem';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import type { ContractAbiItem, FormSubmitResult } from './types';
import config from 'configs/app';
import { getNativeCoinValue } from './utils';
interface Params {
item: ContractAbiItem;
args: Array<unknown>;
addressHash: string;
}
export default function useCallMethodWalletClient(): (params: Params) => Promise<FormSubmitResult> {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
return React.useCallback(async({ args, item, addressHash }) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
if (!walletClient) {
throw new Error('Wallet Client is not defined');
}
if (chainId && String(chainId) !== config.chain.id) {
await switchChainAsync?.({ chainId: Number(config.chain.id) });
}
if (item.type === 'receive' || item.type === 'fallback') {
const value = getNativeCoinValue(args[0]);
const hash = await walletClient.sendTransaction({
to: addressHash as `0x${ string }` | undefined,
value,
});
return { source: 'wallet_client', result: { hash } };
}
const methodName = item.name;
if (!methodName) {
throw new Error('Method name is not defined');
}
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const hash = await walletClient.writeContract({
args: _args,
// Here we provide the ABI as an array containing only one item from the submitted form.
// This is a workaround for the issue with the "viem" library.
// It lacks a "method_id" field to uniquely identify the correct method and instead attempts to find a method based on its name.
// But the name is not unique in the contract ABI and this behavior in the "viem" could result in calling the wrong method.
// See related issues:
// - https://github.com/blockscout/frontend/issues/1032,
// - https://github.com/blockscout/frontend/issues/1327
abi: [ item ] as Abi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value,
});
return { source: 'wallet_client', result: { hash } };
}, [ chainId, isConnected, switchChainAsync, walletClient ]);
}
import React from 'react';
import type { FormSubmitHandler } from './types';
import config from 'configs/app';
import useCallMethodApi from './useCallMethodApi';
import useCallMethodWalletClient from './useCallMethodWalletClient';
interface Params {
tab: string;
addressHash: string;
}
function useFormSubmit({ tab, addressHash }: Params): FormSubmitHandler {
const callMethodApi = useCallMethodApi();
const callMethodWalletClient = useCallMethodWalletClient();
return React.useCallback(async(item, args, strategy) => {
switch (strategy) {
case 'api': {
if (!('method_id' in item)) {
throw new Error('Method ID is not defined');
}
return callMethodApi({
args,
methodId: item.method_id,
addressHash,
isCustomAbi: tab === 'read_custom_methods' || tab === 'write_custom_methods',
isProxy: tab === 'read_proxy' || tab === 'write_proxy',
});
}
case 'wallet_client': {
return callMethodWalletClient({ args, item, addressHash });
}
default: {
throw new Error(`Unknown call strategy "${ strategy }"`);
}
}
}, [ addressHash, callMethodApi, callMethodWalletClient, tab ]);
}
function useFormSubmitFallback({ tab, addressHash }: Params): FormSubmitHandler {
const callMethodApi = useCallMethodApi();
return React.useCallback(async(item, args, strategy) => {
switch (strategy) {
case 'api': {
if (!('method_id' in item)) {
throw new Error('Method ID is not defined');
}
return callMethodApi({
args,
methodId: item.method_id,
addressHash,
isCustomAbi: tab === 'read_custom_methods' || tab === 'write_custom_methods',
isProxy: tab === 'read_proxy' || tab === 'write_proxy',
});
}
default: {
throw new Error(`Unknown call strategy "${ strategy }"`);
}
}
}, [ addressHash, callMethodApi, tab ]);
}
const hook = config.features.blockchainInteraction.isEnabled ? useFormSubmit : useFormSubmitFallback;
export default hook;
import React from 'react';
import { scroller } from 'react-scroll';
import type { ContractAbi } from './types';
export const getElementName = (id: string) => `method_${ id }`;
export default function useScrollToMethod(data: ContractAbi, onScroll: (indices: Array<number>) => void) {
React.useEffect(() => {
const id = window.location.hash.replace('#', '');
if (!id) {
return;
}
const index = data.findIndex((item) => 'method_id' in item && item.method_id === id);
if (index > -1) {
scroller.scrollTo(getElementName(id), {
duration: 500,
smooth: true,
offset: -100,
});
onScroll([ index ]);
}
}, [ data, onScroll ]);
}
export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
return BigInt(0);
}
return BigInt(value);
};
import { Alert, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract';
import useApiFetch from 'lib/api/useApiFetch';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import useAccount from 'lib/web3/useAccount';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount';
interface Props {
isLoading?: boolean;
}
const ContractRead = ({ isLoading }: Props) => {
const apiFetch = useApiFetch();
const account = useWatchAccount();
const { address } = useAccount();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
......@@ -37,55 +30,13 @@ const ContractRead = ({ isLoading }: Props) => {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
from: account?.address,
from: address,
},
queryOptions: {
enabled: !isLoading,
},
});
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<unknown>) => {
return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
fetchParams: {
method: 'POST',
body: {
args,
method_id: item.method_id,
contract_type: isProxy ? 'proxy' : 'regular',
from: account?.address,
},
},
});
}, [ account?.address, addressHash, apiFetch, isCustomAbi, isProxy ]);
const renderItemContent = React.useCallback((item: SmartContractReadMethod, index: number, id: number) => {
if (item.error) {
return <Alert status="error" fontSize="sm" wordBreak="break-word">{ item.error }</Alert>;
}
if (item.outputs?.some(({ value }) => value !== undefined && value !== null)) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ item.outputs.map((output, index) => <ContractMethodConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodForm
key={ id + '_' + index }
data={ item }
onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractReadResult }
methodType="read"
/>
);
}, [ handleMethodFormSubmit ]);
if (isError) {
return <DataFetchAlert/>;
}
......@@ -101,9 +52,9 @@ const ContractRead = ({ isLoading }: Props) => {
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
{ account && <ContractConnectWallet/> }
{ config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
<ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="read"/>
</>
);
};
......
import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractMethodReadResult } from './types';
import type { SmartContractQueryMethodReadSuccess, SmartContractReadMethod } from 'types/api/contract';
import hexToUtf8 from 'lib/hexToUtf8';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
const ContractReadResultError = ({ children }: {children: React.ReactNode}) => {
return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children }
</Alert>
);
};
interface ItemProps {
output: SmartContractQueryMethodReadSuccess['result']['output'][0];
name: SmartContractQueryMethodReadSuccess['result']['names'][0];
}
const ContractReadResultItem = ({ output, name }: ItemProps) => {
if (Array.isArray(name)) {
const [ structName, argNames ] = name;
const argTypes = output.type.match(TUPLE_TYPE_REGEX)?.[1].split(',');
return (
<>
<p>
<chakra.span fontWeight={ 500 }> { structName }</chakra.span>
<span> ({ output.type }) :</span>
</p>
{ argNames.map((argName, argIndex) => {
return (
<p key={ argName }>
<chakra.span fontWeight={ 500 }> { argName }</chakra.span>
<span>{ argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) }</span>
</p>
);
}) }
</>
);
}
return (
<p>
<span> </span>
{ name && <chakra.span fontWeight={ 500 }>{ name } </chakra.span> }
<span>({ output.type }) : { String(output.value) }</span>
</p>
);
};
interface Props {
item: SmartContractReadMethod;
result: ContractMethodReadResult;
onSettle: () => void;
}
const ContractReadResult = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractReadResultError>{ result.statusText }</ContractReadResultError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractReadResultError>{ result.result.error }</ContractReadResultError>;
}
if ('message' in result.result) {
return <ContractReadResultError>[{ result.result.code }] { result.result.message }</ContractReadResultError>;
}
if ('raw' in result.result) {
return <ContractReadResultError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractReadResultError>;
}
if ('method_id' in result.result) {
return <ContractReadResultError>{ JSON.stringify(result.result, undefined, 2) }</ContractReadResultError>;
}
return <ContractReadResultError>Something went wrong.</ContractReadResultError>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p>
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map((output, index) => <ContractReadResultItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractReadResult);
import { useRouter } from 'next/router';
import React from 'react';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import type { SmartContractWriteMethod } from 'types/api/contract';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractWriteResult from './ContractWriteResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils';
interface Props {
isLoading?: boolean;
}
const ContractWrite = ({ isLoading }: Props) => {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
......@@ -46,63 +35,6 @@ const ContractWrite = ({ isLoading }: Props) => {
},
});
const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<unknown>) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
if (chainId && String(chainId) !== config.chain.id) {
await switchChainAsync?.({ chainId: Number(config.chain.id) });
}
if (!contractAbi) {
throw new Error('Something went wrong. Try again later.');
}
if (item.type === 'receive' || item.type === 'fallback') {
const value = getNativeCoinValue(args[0]);
const hash = await walletClient?.sendTransaction({
to: addressHash as `0x${ string }` | undefined,
value,
});
return { hash };
}
const methodName = item.name;
if (!methodName) {
throw new Error('Method name is not defined');
}
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const abi = prepareAbi(contractAbi, item);
const hash = await walletClient?.writeContract({
args: _args,
abi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value,
});
return { hash };
}, [ isConnected, chainId, contractAbi, walletClient, addressHash, switchChainAsync ]);
const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return (
<ContractMethodForm
key={ id + '_' + index }
data={ item }
onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractWriteResult }
methodType="write"
/>
);
}, [ handleMethodFormSubmit ]);
if (isError) {
return <DataFetchAlert/>;
}
......@@ -118,9 +50,9 @@ const ContractWrite = ({ isLoading }: Props) => {
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/>
{ config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
<ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="write"/>
</>
);
};
......
import React from 'react';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ResultComponentProps } from './methodForm/types';
import type { ContractMethodWriteResult } from './types';
import type { SmartContractWriteMethod } from 'types/api/contract';
import ContractWriteResultDumb from './ContractWriteResultDumb';
const ContractWriteResult = ({ result, onSettle }: ResultComponentProps<SmartContractWriteMethod>) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractWriteResultDumb result={ result as ContractMethodWriteResult } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export default React.memo(ContractWriteResult) as typeof ContractWriteResult;
import type { ContractMethodCallResult } from '../types';
import type { SmartContractMethod } from 'types/api/contract';
export interface ResultComponentProps<T extends SmartContractMethod> {
item: T;
result: ContractMethodCallResult<T>;
onSettle: () => void;
}
import type { SmartContractMethodArgType } from 'types/api/contract';
import { INT_REGEXP, getIntBoundaries } from './utils';
interface Params {
argType: SmartContractMethodArgType;
}
export interface MatchInt {
isUnsigned: boolean;
power: string;
min: bigint;
max: bigint;
}
export default function useArgTypeMatchInt({ argType }: Params): MatchInt | null {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
}
const [ , isUnsigned, power = '256' ] = match;
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
return { isUnsigned: Boolean(isUnsigned), power, min, max };
}
import type { SmartContractQueryMethodRead, SmartContractMethod, SmartContractReadMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
export type MethodFormFields = Record<string, string | Array<string>>;
export type MethodFormFieldsFormatted = Record<string, MethodArgType>;
export type MethodArgType = string | boolean | Array<MethodArgType>;
export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError;
export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined;
export type ContractMethodCallResult<T extends SmartContractMethod> =
T extends SmartContractReadMethod ? ContractMethodReadResult : ContractMethodWriteResult;
import { useQueryClient } from '@tanstack/react-query';
import type { Abi } from 'abitype';
import React from 'react';
import type { Address } from 'types/api/address';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
interface Params {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
export default function useContractAbi({ addressHash, isProxy, isCustomAbi }: Params): Abi | undefined {
const queryClient = useQueryClient();
const { data: contractInfo } = useApiQuery('contract', {
pathParams: { hash: addressHash },
queryOptions: {
enabled: Boolean(addressHash),
refetchOnMount: false,
},
});
const addressInfo = queryClient.getQueryData<Address>(getResourceKey('address', {
pathParams: { hash: addressHash },
}));
const { data: proxyInfo } = useApiQuery('contract', {
pathParams: { hash: addressInfo?.implementation_address || '' },
queryOptions: {
enabled: Boolean(addressInfo?.implementation_address),
refetchOnMount: false,
},
});
const { data: customInfo } = useApiQuery('contract_methods_write', {
pathParams: { hash: addressHash },
queryParams: { is_custom_abi: 'true' },
queryOptions: {
enabled: Boolean(contractInfo?.has_custom_methods_write),
refetchOnMount: false,
},
});
return React.useMemo(() => {
if (isProxy) {
return proxyInfo?.abi ?? undefined;
}
if (isCustomAbi) {
return customInfo as Abi;
}
return contractInfo?.abi ?? undefined;
}, [ contractInfo?.abi, customInfo, isCustomAbi, isProxy, proxyInfo?.abi ]);
}
import { watchAccount, getAccount } from '@wagmi/core';
import React from 'react';
import type { Config } from 'wagmi';
import { useConfig } from 'wagmi';
export function getWalletAccount(config: Config) {
try {
return getAccount(config);
} catch (error) {
return null;
}
}
export default function useWatchAccount() {
const config = useConfig();
const [ account, setAccount ] = React.useState(getWalletAccount(config));
React.useEffect(() => {
if (!account) {
return;
}
return watchAccount(config, {
onChange(account) {
setAccount(account);
},
});
}, [ account, config ]);
return account;
}
import { prepareAbi } from './utils';
describe('function prepareAbi()', () => {
const commonAbi = [
{
inputs: [
{ internalType: 'address', name: '_pool', type: 'address' },
{ internalType: 'address', name: '_token', type: 'address' },
{ internalType: 'uint256', name: '_denominator', type: 'uint256' },
],
stateMutability: 'nonpayable' as const,
type: 'constructor' as const,
},
{
anonymous: false,
inputs: [
{ indexed: false, internalType: 'uint256[]', name: 'indices', type: 'uint256[]' },
],
name: 'CompleteDirectDepositBatch',
type: 'event' as const,
},
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
{ internalType: 'string', name: '_zkAddress', type: 'string' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable' as const,
type: 'function' as const,
},
];
const method = {
inputs: [
{ internalType: 'address' as const, name: '_fallbackUser', type: 'address' as const },
{ internalType: 'string' as const, name: '_zkAddress', type: 'string' as const },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256' as const, name: '', type: 'uint256' as const },
],
stateMutability: 'payable' as const,
type: 'function' as const,
constant: false,
payable: true,
method_id: '0x2e0e2d3e',
};
it('if there is only one method with provided name, does nothing', () => {
const abi = prepareAbi(commonAbi, method);
expect(abi).toHaveLength(commonAbi.length);
});
it('if there are two or more methods with the same name and inputs length, filters out those which input types are not matched', () => {
const abi = prepareAbi([
...commonAbi,
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
{ internalType: 'bytes', name: '_rawZkAddress', type: 'bytes' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable',
type: 'function',
},
], method);
expect(abi).toHaveLength(commonAbi.length);
const item = abi.find((item) => 'name' in item ? item.name === method.name : false);
expect(item).toEqual(commonAbi[2]);
});
it('if there are two or more methods with the same name and different inputs length, filters out those which inputs are not matched', () => {
const abi = prepareAbi([
...commonAbi,
{
inputs: [
{ internalType: 'address', name: '_fallbackUser', type: 'address' },
],
name: 'directNativeDeposit',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
],
stateMutability: 'payable',
type: 'function',
},
], method);
expect(abi).toHaveLength(commonAbi.length);
const item = abi.find((item) => 'name' in item ? item.name === method.name : false);
expect(item).toEqual(commonAbi[2]);
});
});
import type { Abi } from 'abitype';
import type { SmartContractWriteMethod } from 'types/api/contract';
export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
return BigInt(0);
}
return BigInt(value);
};
export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
if ('name' in item) {
const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1;
if (hasMethodsWithSameName) {
return abi.filter((abiItem) => {
if (!('name' in abiItem)) {
return true;
}
if (abiItem.name !== item.name) {
return true;
}
if (abiItem.inputs.length !== item.inputs.length) {
return false;
}
return abiItem.inputs.every(({ name, type }) => {
const itemInput = item.inputs.find((input) => input.name === name);
return Boolean(itemInput) && itemInput?.type === type;
});
});
}
}
return abi;
}
import React from 'react';
import * as blockMock from 'mocks/blocks/block';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TestApp from 'playwright/TestApp';
import * as configs from 'playwright/utils/configs';
import BlockDetails from './BlockDetails';
import type { BlockQuery } from './useBlockQuery';
......@@ -15,43 +13,33 @@ const hooksConfig = {
},
};
test('regular block +@mobile +@dark-mode', async({ mount, page }) => {
test('regular block +@mobile +@dark-mode', async({ render, page }) => {
const query = {
data: blockMock.base,
isPending: false,
} as BlockQuery;
const component = await mount(
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
const component = await render(<BlockDetails query={ query }/>, { hooksConfig });
await page.getByText('View details').click();
await expect(component).toHaveScreenshot();
});
test('genesis block', async({ mount, page }) => {
test('genesis block', async({ render, page }) => {
const query = {
data: blockMock.genesis,
isPending: false,
} as BlockQuery;
const component = await mount(
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
const component = await render(<BlockDetails query={ query }/>, { hooksConfig });
await page.getByText('View details').click();
await expect(component).toHaveScreenshot();
});
test('with blob txs', async({ mount, page, mockEnvs }) => {
test('with blob txs', async({ render, page, mockEnvs }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_DATA_AVAILABILITY_ENABLED', 'true' ],
]);
......@@ -60,35 +48,21 @@ test('with blob txs', async({ mount, page, mockEnvs }) => {
isPending: false,
} as BlockQuery;
const component = await mount(
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
const component = await render(<BlockDetails query={ query }/>, { hooksConfig });
await page.getByText('View details').click();
await expect(component).toHaveScreenshot();
});
const customFieldsTest = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any,
});
customFieldsTest('rootstock custom fields', async({ mount, page }) => {
test('rootstock custom fields', async({ render, page, mockEnvs }) => {
await mockEnvs(ENVS_MAP.blockHiddenFields);
const query = {
data: blockMock.rootstock,
isPending: false,
} as BlockQuery;
const component = await mount(
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
const component = await render(<BlockDetails query={ query }/>, { hooksConfig });
await page.getByText('View details').click();
......
......@@ -220,8 +220,7 @@ const BlockDetails = ({ query }: Props) => {
</DetailsInfoItem>
) }
{ rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && (
<>
{ rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && !config.UI.views.block.hiddenFields?.batch && (
<DetailsInfoItem
title="Batch"
hint="Batch number"
......@@ -234,6 +233,8 @@ const BlockDetails = ({ query }: Props) => {
/>
) : <Skeleton isLoaded={ !isPlaceholderData }>Pending</Skeleton> }
</DetailsInfoItem>
) }
{ rollupFeature.isEnabled && rollupFeature.type === 'zkSync' && data.zksync && !config.UI.views.block.hiddenFields?.L1_status && (
<DetailsInfoItem
title="Status"
hint="Status is the short interpretation of the batch lifecycle"
......@@ -241,7 +242,6 @@ const BlockDetails = ({ query }: Props) => {
>
<VerificationSteps steps={ ZKSYNC_L2_TX_BATCH_STATUSES } currentStep={ data.zksync.status } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
</>
) }
{ !config.UI.views.block.hiddenFields?.miner && (
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as statsMock from 'mocks/stats/index';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import { test, expect } from 'playwright/lib';
import * as configs from 'playwright/utils/configs';
import GasTrackerPriceSnippet from './GasTrackerPriceSnippet';
......@@ -13,48 +11,57 @@ test.use({ viewport: configs.viewport.md });
const data = statsMock.base.gas_prices.fast;
const clip = { x: 0, y: 0, width: 334, height: 204 };
test('with usd as primary unit +@dark-mode', async({ mount, page }) => {
await mount(
<TestApp>
test('with usd as primary unit +@dark-mode', async({ render, page }) => {
await render(
<GasTrackerPriceSnippet
data={ data }
type="fast"
isLoading={ false }
/>
</TestApp>,
/>,
);
await expect(page).toHaveScreenshot({ clip });
});
test('loading state', async({ mount, page }) => {
await mount(
<TestApp>
test('loading state', async({ render, page }) => {
await render(
<GasTrackerPriceSnippet
data={ data }
type="fast"
isLoading={ true }
/>
</TestApp>,
/>,
);
await expect(page).toHaveScreenshot({ clip });
});
const gweiUnitsTest = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_GAS_TRACKER_UNITS', value: '["gwei","usd"]' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
test('with gwei as primary unit +@dark-mode', async({ render, page, mockEnvs }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_GAS_TRACKER_UNITS', '["gwei","usd"]' ],
]);
await render(
<GasTrackerPriceSnippet
data={ data }
type="slow"
isLoading={ false }
/>,
);
await expect(page).toHaveScreenshot({ clip });
});
gweiUnitsTest('with gwei as primary unit +@dark-mode', async({ mount, page }) => {
await mount(
<TestApp>
test('with zero values', async({ render, page }) => {
const data = {
fiat_price: '1.74',
price: 0.0,
time: 0,
base_fee: 0,
priority_fee: 0,
};
await render(
<GasTrackerPriceSnippet
data={ data }
type="slow"
isLoading={ false }
/>
</TestApp>,
/>,
);
await expect(page).toHaveScreenshot({ clip });
});
......@@ -50,14 +50,14 @@ const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => {
</Skeleton>
</Flex>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 3 } w="fit-content">
{ data.price && data.fiat_price && <GasPrice data={ data } prefix={ `${ asymp } ` } unitMode="secondary"/> }
{ data.price !== null && data.fiat_price !== null && <GasPrice data={ data } prefix={ `${ asymp } ` } unitMode="secondary"/> }
<span> per transaction</span>
{ typeof data.time === 'number' && data.time > 0 && <span> / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s</span> }
</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 2 } w="fit-content" whiteSpace="pre">
{ data.base_fee && <span>Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
{ data.base_fee && data.priority_fee && <span> / </span> }
{ data.priority_fee && <span>Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
{ typeof data.base_fee === 'number' && <span>Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
{ typeof data.base_fee === 'number' && typeof data.priority_fee === 'number' && <span> / </span> }
{ typeof data.priority_fee === 'number' && <span>Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
</Skeleton>
</Box>
);
......
import { test as base, expect } from '@playwright/experimental-ct-react';
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 TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { test, expect } from 'playwright/lib';
import LatestBlocks from './LatestBlocks';
const STATS_API_URL = buildApiUrl('stats');
const BLOCKS_API_URL = buildApiUrl('homepage_blocks');
export const test = base.extend<socketServer.SocketServerFixture>({
createSocket: socketServer.createSocket,
});
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
blockMock.base,
blockMock.base2,
]),
}));
const component = await mount(
<TestApp>
<LatestBlocks/>
</TestApp>,
);
test('default view +@mobile +@dark-mode', async({ render, mockApiResponse }) => {
await mockApiResponse('stats', statsMock.base);
await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]);
const component = await render(<LatestBlocks/>);
await expect(component).toHaveScreenshot();
});
const testL2 = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
testL2('L2 view', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
blockMock.base,
blockMock.base2,
]),
}));
const component = await mount(
<TestApp>
<LatestBlocks/>
</TestApp>,
);
test('L2 view', async({ render, mockEnvs, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.optimisticRollup);
await mockApiResponse('stats', statsMock.base);
await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]);
const component = await render(<LatestBlocks/>);
await expect(component).toHaveScreenshot();
});
const testNoReward = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any,
});
testNoReward('no reward view', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
blockMock.base,
blockMock.base2,
]),
}));
const component = await mount(
<TestApp>
<LatestBlocks/>
</TestApp>,
);
test('no reward view', async({ render, mockEnvs, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.blockHiddenFields);
await mockApiResponse('stats', statsMock.base);
await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]);
const component = await render(<LatestBlocks/>);
await expect(component).toHaveScreenshot();
});
test('with long block height', async({ mount, page }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
{
...blockMock.base,
height: 123456789012345,
},
]),
}));
const component = await mount(
<TestApp>
<LatestBlocks/>
</TestApp>,
);
test('with long block height', async({ render, mockApiResponse }) => {
await mockApiResponse('stats', statsMock.base);
await mockApiResponse('homepage_blocks', [ { ...blockMock.base, height: 123456789012345 } ]);
const component = await render(<LatestBlocks/>);
await expect(component).toHaveScreenshot();
});
test.describe('socket', () => {
test.describe.configure({ mode: 'serial' });
test('new item', async({ mount, page, createSocket }) => {
await page.route(STATS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(statsMock.base),
}));
await page.route(BLOCKS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
blockMock.base,
blockMock.base2,
]),
}));
const component = await mount(
<TestApp withSocket>
<LatestBlocks/>
</TestApp>,
);
test('new item', async({ render, mockApiResponse, createSocket }) => {
await mockApiResponse('stats', statsMock.base);
await mockApiResponse('homepage_blocks', [ blockMock.base, blockMock.base2 ]);
const component = await render(<LatestBlocks/>, undefined, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, 'blocks:new_block');
socketServer.sendMessage(socket, channel, 'new_block', {
......@@ -150,7 +54,6 @@ test.describe('socket', () => {
timestamp: '2022-11-11T11:59:58Z',
},
});
await expect(component).toHaveScreenshot();
});
});
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as depositMock from 'mocks/l2deposits/deposits';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import LatestDeposits from './LatestDeposits';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(buildApiUrl('homepage_deposits'), (route) => route.fulfill({
status: 200,
body: JSON.stringify(depositMock.data.items),
}));
const component = await mount(
<TestApp>
<LatestDeposits/>
</TestApp>,
);
test('default view +@mobile +@dark-mode', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.optimisticRollup);
mockApiResponse('homepage_deposits', depositMock.data.items);
const component = await render(<LatestDeposits/>);
await expect(component).toHaveScreenshot();
});
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { txnBatchesData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import LatestZkEvmL2Batches from './LatestZkEvmL2Batches';
const BATCHES_API_URL = buildApiUrl('homepage_zkevm_l2_batches');
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkEvmRollup) as any,
});
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(BATCHES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchesData),
}));
const component = await mount(
<TestApp>
<LatestZkEvmL2Batches/>
</TestApp>,
);
test('default view +@mobile +@dark-mode', async({ render, mockEnvs, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.zkEvmRollup);
await mockApiResponse('homepage_zkevm_l2_batches', txnBatchesData);
const component = await render(<LatestZkEvmL2Batches/>);
await expect(component).toHaveScreenshot();
});
......@@ -20,7 +20,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
text={
(selectedCategoryId === MarketplaceCategory.FAVORITES && !favoriteApps.length) ? (
<>
You don{ apos }t have any favorite apps.
You don{ apos }t have any favorite apps.<br/>
Click on the <IconSvg name="star_outline" w={ 4 } h={ 4 } mb={ -0.5 }/> icon on the app{ apos }s card to add it to Favorites.
</>
) : (
......@@ -28,7 +28,7 @@ const EmptySearchResult = ({ favoriteApps, selectedCategoryId }: Props) => (
No matching apps found.
{ 'suggestIdeasFormUrl' in feature && (
<>
{ ' ' }Have a groundbreaking idea or app suggestion?{ ' ' }
{ ' ' }Have a groundbreaking idea or app suggestion?<br/>
<LinkExternal href={ feature.suggestIdeasFormUrl }>Share it with us</LinkExternal>
</>
) }
......
......@@ -7,7 +7,6 @@ import React, { useCallback } from 'react';
import type { MarketplaceAppWithSecurityReport } from 'types/client/marketplace';
import { ContractListTypes } from 'types/client/marketplace';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index';
......@@ -33,7 +32,6 @@ const MarketplaceAppModal = ({
data,
showContractList: showContractListProp,
}: Props) => {
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const starOutlineIconColor = useColorModeValue('gray.600', 'gray.300');
const {
......@@ -47,6 +45,7 @@ const MarketplaceAppModal = ({
github,
telegram,
twitter,
discord,
logo,
logoDarkMode,
categories,
......@@ -62,6 +61,10 @@ const MarketplaceAppModal = ({
icon: 'social/twitter_filled' as IconName,
url: twitter,
} : null,
discord ? {
icon: 'social/discord_filled' as IconName,
url: discord,
} : null,
].filter(Boolean);
if (github) {
......@@ -183,7 +186,7 @@ const MarketplaceAppModal = ({
/>
</Flex>
{ (isExperiment && securityReport) && (
{ securityReport && (
<Flex alignItems="center" gap={ 3 }>
<AppSecurityReport
id={ id }
......
......@@ -8,7 +8,6 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import { useAppContext } from 'lib/contexts/app';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
......@@ -31,7 +30,6 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
const [ showContractList, setShowContractList ] = useBoolean(false);
const appProps = useAppContext();
const isMobile = useIsMobile();
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const goBackUrl = React.useMemo(() => {
if (appProps.referrer && appProps.referrer.includes('/apps') && !appProps.referrer.includes('/apps/')) {
......@@ -72,7 +70,7 @@ const MarketplaceAppTopBar = ({ data, isLoading, securityReport }: Props) => {
<Skeleton isLoaded={ !isLoading }>
<MarketplaceAppInfo data={ data }/>
</Skeleton>
{ (isExperiment && (securityReport || isLoading)) && (
{ (securityReport || isLoading) && (
<AppSecurityReport
id={ data?.id || '' }
securityReport={ securityReport }
......
......@@ -32,7 +32,7 @@ const MarketplaceListWithScores = ({
showContractList,
}: Props) => {
const displayedApps = React.useMemo(() => apps.sort((a, b) => {
const displayedApps = React.useMemo(() => [ ...apps ].sort((a, b) => {
if (!a.securityReport) {
return 1;
} else if (!b.securityReport) {
......
......@@ -18,13 +18,13 @@ function isAppNameMatches(q: string, app: MarketplaceAppWithSecurityReport) {
return app.title.toLowerCase().includes(q.toLowerCase());
}
function isAppCategoryMatches(category: string, app: MarketplaceAppWithSecurityReport, favoriteApps: Array<string>) {
function isAppCategoryMatches(category: string, app: MarketplaceAppWithSecurityReport, favoriteApps: Array<string> = []) {
return category === MarketplaceCategory.ALL ||
(category === MarketplaceCategory.FAVORITES && favoriteApps.includes(app.id)) ||
app.categories.includes(category);
}
function sortApps(apps: Array<MarketplaceAppWithSecurityReport>, favoriteApps: Array<string>) {
function sortApps(apps: Array<MarketplaceAppWithSecurityReport>, favoriteApps: Array<string> = []) {
return apps.sort((a, b) => {
const priorityA = a.priority || 0;
const priorityB = b.priority || 0;
......@@ -60,17 +60,19 @@ export default function useMarketplaceApps(
const { data: securityReports, isPlaceholderData: isSecurityReportsPlaceholderData } = useSecurityReports();
// Update favorite apps only when selectedCategoryId changes to avoid sortApps to be called on each favorite app click
// Set the value only 1 time to avoid unnecessary useQuery calls and re-rendering of all applications
const [ snapshotFavoriteApps, setSnapshotFavoriteApps ] = React.useState<Array<string> | undefined>();
const isInitialSetup = React.useRef(true);
React.useEffect(() => {
if (isFavoriteAppsLoaded) {
setSnapshotFavoriteApps(favoriteApps);
if (isInitialSetup.current && (isFavoriteAppsLoaded || favoriteApps === undefined)) {
setSnapshotFavoriteApps(favoriteApps || []);
isInitialSetup.current = false;
}
}, [ selectedCategoryId, isFavoriteAppsLoaded ]); // eslint-disable-line react-hooks/exhaustive-deps
}, [ isFavoriteAppsLoaded, favoriteApps ]);
const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppWithSecurityReport>>({
queryKey: [ 'marketplace-dapps', snapshotFavoriteApps, favoriteApps ],
queryKey: [ 'marketplace-dapps', snapshotFavoriteApps ],
queryFn: async() => {
if (!feature.isEnabled) {
return [];
......@@ -80,10 +82,10 @@ export default function useMarketplaceApps(
return apiFetch('marketplace_dapps', { pathParams: { chainId: config.chain.id } });
}
},
select: (data) => sortApps(data as Array<MarketplaceAppWithSecurityReport>, snapshotFavoriteApps || []),
select: (data) => sortApps(data as Array<MarketplaceAppWithSecurityReport>, snapshotFavoriteApps),
placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined,
staleTime: Infinity,
enabled: feature.isEnabled && (!favoriteApps || Boolean(snapshotFavoriteApps)),
enabled: feature.isEnabled && Boolean(snapshotFavoriteApps),
});
const appsWithSecurityReports = React.useMemo(() =>
......@@ -91,7 +93,7 @@ export default function useMarketplaceApps(
[ data, securityReports ]);
const displayedApps = React.useMemo(() => {
return appsWithSecurityReports?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps || [])) || [];
return appsWithSecurityReports?.filter(app => isAppNameMatches(filter, app) && isAppCategoryMatches(selectedCategoryId, app, favoriteApps)) || [];
}, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps ]);
return React.useMemo(() => ({
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as addressCountersMock from 'mocks/address/counters';
import * as addressTabCountersMock from 'mocks/address/tabCounters';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib';
import Address from './Address';
const hooksConfig = {
router: {
query: { hash: addressMock.hash },
},
};
test.describe('fetched bytecode', () => {
test('should refetch address query', async({ render, mockApiResponse, createSocket, page }) => {
const addressApiUrl = await mockApiResponse('address', addressMock.validator, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('address_counters', addressCountersMock.forValidator, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('address_tabs_counters', addressTabCountersMock.base, { pathParams: { hash: addressMock.hash } });
await mockApiResponse('address_txs', { items: [], next_page_params: null }, { pathParams: { hash: addressMock.hash } });
await render(<Address/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
const channel = await socketServer.joinChannel(socket, `addresses:${ addressMock.hash.toLowerCase() }`);
socketServer.sendMessage(socket, channel, 'fetched_bytecode', { fetched_bytecode: '0x0123' });
const request = await page.waitForRequest(addressApiUrl);
expect(request).toBeTruthy();
});
});
......@@ -2,14 +2,18 @@ import { Box, Flex, HStack, useColorModeValue } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { EntityTag } from 'ui/shared/EntityTags/types';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import useContractTabs from 'lib/hooks/useContractTabs';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { ADDRESS_TABS_COUNTERS } from 'stubs/address';
import { USER_OPS_ACCOUNT } from 'stubs/userOps';
import AddressAccountHistory from 'ui/address/AddressAccountHistory';
......@@ -34,7 +38,9 @@ import TextAd from 'ui/shared/ad/TextAd';
import AddressAddToWallet from 'ui/shared/address/AddressAddToWallet';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import EntityTags from 'ui/shared/EntityTags';
import EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import sortEntityTags from 'ui/shared/EntityTags/sortEntityTags';
import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -69,14 +75,31 @@ const AddressPageContent = () => {
},
});
const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
const handleFetchedBytecodeMessage = React.useCallback(() => {
addressQuery.refetch();
}, [ addressQuery ]);
const channel = useSocketChannel({
topic: `addresses:${ hash?.toLowerCase() }`,
isDisabled: isTabsLoading || addressQuery.isDegradedData || Boolean(addressQuery.data?.is_contract),
});
useSocketMessage({
channel,
event: 'fetched_bytecode',
handler: handleFetchedBytecodeMessage,
});
const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined);
const safeIconColor = useColorModeValue('black', 'white');
const contractTabs = useContractTabs(addressQuery.data, addressQuery.isPlaceholderData);
const isLoading = addressQuery.isPlaceholderData || (config.features.userOps.isEnabled && userOpsAccountQuery.isPlaceholderData);
const isTabsLoading = isLoading || addressTabsCountersQuery.isPlaceholderData;
const tabs: Array<RoutedTab> = React.useMemo(() => {
return [
{
......@@ -169,18 +192,27 @@ const AddressPageContent = () => {
].filter(Boolean);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data, isTabsLoading ]);
const tags = (
const tags: Array<EntityTag> = React.useMemo(() => {
return [
!addressQuery.data?.is_contract ? { slug: 'eoa', name: 'EOA', tagType: 'custom' as const, ordinal: -1 } : undefined,
config.features.validators.isEnabled && addressQuery.data?.has_validated_blocks ?
{ slug: 'validator', name: 'Validator', tagType: 'custom' as const, ordinal: 10 } :
undefined,
addressQuery.data?.implementation_address ? { slug: 'proxy', name: 'Proxy', tagType: 'custom' as const, ordinal: -1 } : undefined,
addressQuery.data?.token ? { slug: 'token', name: 'Token', tagType: 'custom' as const, ordinal: -1 } : undefined,
isSafeAddress ? { slug: 'safe', name: 'Multisig: Safe', tagType: 'custom' as const, ordinal: -10 } : undefined,
config.features.userOps.isEnabled && userOpsAccountQuery.data ?
{ slug: 'user_ops_acc', name: 'Smart contract wallet', tagType: 'custom' as const, ordinal: -10 } :
undefined,
...formatUserTags(addressQuery.data),
...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []),
].filter(Boolean).sort(sortEntityTags);
}, [ addressMetadataQuery.data, addressQuery.data, hash, isSafeAddress, userOpsAccountQuery.data ]);
const titleContentAfter = (
<EntityTags
data={ addressQuery.data }
isLoading={ isLoading }
tagsBefore={ [
!addressQuery.data?.is_contract ? { label: 'eoa', display_name: 'EOA' } : undefined,
config.features.validators.isEnabled && addressQuery.data?.has_validated_blocks ? { label: 'validator', display_name: 'Validator' } : undefined,
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined,
config.features.userOps.isEnabled && userOpsAccountQuery.data ? { label: 'user_ops_acc', display_name: 'Smart contract wallet' } : undefined,
] }
tags={ tags }
isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) }
/>
);
......@@ -245,7 +277,7 @@ const AddressPageContent = () => {
<PageTitle
title={ `${ addressQuery.data?.is_contract ? 'Contract' : 'Address' } details` }
backLink={ backLink }
contentAfter={ tags }
contentAfter={ titleContentAfter }
secondRow={ titleSecondRow }
isLoading={ isLoading }
/>
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { data as withdrawalsData } from 'mocks/withdrawals/withdrawals';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import BeaconChainWithdrawals from './BeaconChainWithdrawals';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.beaconChain) as any,
});
const WITHDRAWALS_API_URL = buildApiUrl('withdrawals');
const WITHDRAWALS_COUNTERS_API_URL = buildApiUrl('withdrawals_counters');
test('base view +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(WITHDRAWALS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withdrawalsData),
}));
await page.route(WITHDRAWALS_COUNTERS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ withdrawal_count: '111111', withdrawal_sum: '1010101010110101001101010' }),
}));
const component = await mount(
<TestApp>
<BeaconChainWithdrawals/>
</TestApp>,
);
test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.beaconChain);
await mockTextAd();
await mockApiResponse('withdrawals', withdrawalsData);
await mockApiResponse('withdrawals_counters', { withdrawal_count: '111111', withdrawal_sum: '1010101010110101001101010' });
const component = await render(<BeaconChainWithdrawals/>);
await expect(component).toHaveScreenshot();
});
import type { BrowserContext } from '@playwright/test';
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';
import Blocks from './Blocks';
......@@ -57,11 +54,8 @@ test.describe('mobile', () => {
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);
......
......@@ -36,7 +36,8 @@ const GasTracker = () => {
rowGap={ 1 }
flexDir={{ base: 'column', lg: 'row' }}
>
{ data?.network_utilization_percentage && <GasTrackerNetworkUtilization percentage={ data.network_utilization_percentage } isLoading={ isLoading }/> }
{ typeof data?.network_utilization_percentage === 'number' &&
<GasTrackerNetworkUtilization percentage={ data.network_utilization_percentage } isLoading={ isLoading }/> }
{ data?.gas_price_updated_at && (
<Skeleton isLoaded={ !isLoading } whiteSpace="pre" display="flex" alignItems="center">
<span>Last updated </span>
......
......@@ -46,14 +46,11 @@ test('with banner +@dark-mode', async({ render, mockEnvs, mockConfigResponse })
await expect(component).toHaveScreenshot();
});
test('with scores +@dark-mode', async({ render, mockConfigResponse, mockEnvs, mockFeatures }) => {
test('with scores +@dark-mode', async({ render, mockConfigResponse, mockEnvs }) => {
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
]);
await mockFeatures([
[ 'security_score_exp', true ],
]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
const component = await render(<Marketplace/>);
await component.getByText('Apps scores').click();
......@@ -95,14 +92,11 @@ test.describe('mobile', () => {
await expect(component).toHaveScreenshot();
});
test('with scores', async({ render, mockConfigResponse, mockEnvs, mockFeatures }) => {
test('with scores', async({ render, mockConfigResponse, mockEnvs }) => {
const MARKETPLACE_SECURITY_REPORTS_URL = 'https://marketplace-security-reports.json';
await mockEnvs([
[ 'NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL ],
]);
await mockFeatures([
[ 'security_score_exp', true ],
]);
await mockConfigResponse('NEXT_PUBLIC_MARKETPLACE_SECURITY_REPORTS_URL', MARKETPLACE_SECURITY_REPORTS_URL, JSON.stringify(securityReportsMock));
const component = await render(<Marketplace/>);
await component.getByText('Apps scores').click();
......
......@@ -7,7 +7,6 @@ import type { TabItem } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import useIsMobile from 'lib/hooks/useIsMobile';
import Banner from 'ui/marketplace/Banner';
import ContractListModal from 'ui/marketplace/ContractListModal';
......@@ -74,7 +73,6 @@ const Marketplace = () => {
} = useMarketplace();
const isMobile = useIsMobile();
const { value: isExperiment } = useFeatureValue('security_score_exp', false);
const categoryTabs = React.useMemo(() => {
const tabs: Array<TabItem> = categories.map(category => ({
......@@ -189,7 +187,7 @@ const Marketplace = () => {
</Box>
<Flex direction={{ base: 'column', lg: 'row' }} mb={{ base: 4, lg: 6 }} gap={{ base: 4, lg: 3 }}>
{ (feature.securityReportsUrl && isExperiment) && (
{ feature.securityReportsUrl && (
<Skeleton isLoaded={ !isPlaceholderData }>
<RadioButtonGroup<MarketplaceDisplayType>
onChange={ onDisplayTypeChange }
......@@ -226,12 +224,12 @@ const Marketplace = () => {
onChange={ onSearchInputChange }
placeholder="Find app by name or keyword..."
isLoading={ isPlaceholderData }
size={ (feature.securityReportsUrl && isExperiment) ? 'xs' : 'sm' }
size={ feature.securityReportsUrl ? 'xs' : 'sm' }
flex="1"
/>
</Flex>
{ (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl && isExperiment) ? (
{ (selectedDisplayType === MarketplaceDisplayType.SCORES && feature.securityReportsUrl) ? (
<MarketplaceListWithScores
apps={ displayedApps }
showAppInfo={ showAppInfo }
......
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { data as depositsData } from 'mocks/l2deposits/deposits';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import OptimisticL2Deposits from './OptimisticL2Deposits';
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
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
test('base view +@mobile', async({ mount, page }) => {
test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
// 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 mockEnvs(ENVS_MAP.optimisticRollup);
await mockTextAd();
await mockApiResponse('optimistic_l2_deposits', depositsData);
await mockApiResponse('optimistic_l2_deposits_count', 3971111);
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(DEPOSITS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(depositsData),
}));
await page.route(DEPOSITS_COUNT_API_URL, (route) => route.fulfill({
status: 200,
body: '3971111',
}));
const component = await mount(
<TestApp>
<OptimisticL2Deposits/>
</TestApp>,
);
const component = await render(<OptimisticL2Deposits/>);
await expect(component).toHaveScreenshot({ timeout: 10_000 });
});
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { outputRootsData } from 'mocks/l2outputRoots/outputRoots';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import OptimisticL2OutputRoots from './OptimisticL2OutputRoots';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
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('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
// 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 page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(OUTPUT_ROOTS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(outputRootsData),
}));
await page.route(OUTPUT_ROOTS_COUNT_API_URL, (route) => route.fulfill({
status: 200,
body: '9927',
}));
const component = await mount(
<TestApp>
<OptimisticL2OutputRoots/>
</TestApp>,
);
await mockEnvs(ENVS_MAP.optimisticRollup);
await mockTextAd();
await mockApiResponse('optimistic_l2_output_roots', outputRootsData);
await mockApiResponse('optimistic_l2_output_roots_count', 9927);
const component = await render(<OptimisticL2OutputRoots/>);
await expect(component).toHaveScreenshot({ timeout: 10_000 });
});
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { txnBatchesData } from 'mocks/l2txnBatches/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import OptimisticL2TxnBatches from './OptimisticL2TxnBatches';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
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('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => {
// 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 page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(TXN_BATCHES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchesData),
}));
await page.route(TXN_BATCHES_COUNT_API_URL, (route) => route.fulfill({
status: 200,
body: '1235016',
}));
const component = await mount(
<TestApp>
<OptimisticL2TxnBatches/>
</TestApp>,
);
await mockTextAd();
await mockEnvs(ENVS_MAP.optimisticRollup);
await mockApiResponse('optimistic_l2_txn_batches', txnBatchesData);
await mockApiResponse('optimistic_l2_txn_batches_count', 1235016);
const component = await render(<OptimisticL2TxnBatches/>);
await expect(component).toHaveScreenshot({ timeout: 10_000 });
});
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { data as withdrawalsData } from 'mocks/l2withdrawals/withdrawals';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import OptimisticL2Withdrawals from './OptimisticL2Withdrawals';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
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('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => {
// 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 page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(WITHDRAWALS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(withdrawalsData),
}));
await page.route(WITHDRAWALS_COUNT_API_URL, (route) => route.fulfill({
status: 200,
body: '397',
}));
const component = await mount(
<TestApp>
<OptimisticL2Withdrawals/>
</TestApp>,
);
await mockTextAd();
await mockEnvs(ENVS_MAP.optimisticRollup);
await mockApiResponse('optimistic_l2_withdrawals', withdrawalsData);
await mockApiResponse('optimistic_l2_withdrawals_count', 397);
const component = await render(<OptimisticL2Withdrawals/>);
await expect(component).toHaveScreenshot({ timeout: 10_000 });
});
......@@ -247,7 +247,7 @@ const TokenPageContent = () => {
<>
<TextAd mb={ 6 }/>
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery }/>
<TokenPageTitle tokenQuery={ tokenQuery } addressQuery={ addressQuery } hash={ hashString }/>
<TokenDetails tokenQuery={ tokenQuery }/>
......
......@@ -10,7 +10,7 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import { publicClient } from 'lib/web3/client';
import TextAd from 'ui/shared/ad/TextAd';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import EntityTags from 'ui/shared/EntityTags';
import EntityTags from 'ui/shared/EntityTags/EntityTags';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
......@@ -77,7 +77,7 @@ const TransactionPageContent = () => {
const tags = (
<EntityTags
isLoading={ isPlaceholderData }
tagsBefore={ [ data?.tx_tag ? { label: data.tx_tag, display_name: data.tx_tag } : undefined ] }
tags={ data?.tx_tag ? [ { slug: data.tx_tag, name: data.tx_tag, tagType: 'private_tag' as const } ] : [] }
/>
);
......
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import { userOpData } from 'mocks/userOps/userOp';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect, devices } from 'playwright/lib';
import UserOp from './UserOp';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
const USER_OP_API_URL = buildApiUrl('user_op', { hash: userOpData.hash });
test('base view', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(USER_OP_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpData),
}));
const component = await mount(
<TestApp>
<UserOp/>
</TestApp>,
{ hooksConfig: {
const hooksConfig = {
router: {
query: { hash: userOpData.hash },
isReady: true,
},
} },
);
};
test.beforeEach(async({ mockEnvs }) => {
await mockEnvs(ENVS_MAP.userOps);
});
test('base view', async({ render, mockTextAd, mockApiResponse }) => {
await mockTextAd();
await mockApiResponse('user_op', userOpData, { pathParams: { hash: userOpData.hash } });
const component = await render(<UserOp/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(USER_OP_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpData),
}));
const component = await mount(
<TestApp>
<UserOp/>
</TestApp>,
{ hooksConfig: {
router: {
query: { hash: userOpData.hash },
isReady: true,
},
} },
);
test('base view', async({ render, mockTextAd, mockApiResponse }) => {
await mockTextAd();
await mockApiResponse('user_op', userOpData, { pathParams: { hash: userOpData.hash } });
const component = await render(<UserOp/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
});
import { Box } from '@chakra-ui/react';
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { userOpsData } from 'mocks/userOps/userOps';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import UserOps from './UserOps';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
const USER_OPS_API_URL = buildApiUrl('user_ops');
test('base view +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(USER_OPS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpsData),
}));
const component = await mount(
<TestApp>
<Box pt={{ base: '106px', lg: 0 }}>
<UserOps/>
</Box>
</TestApp>,
);
test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.userOps);
await mockTextAd();
await mockApiResponse('user_ops', userOpsData);
const component = await render(<Box pt={{ base: '106px', lg: 0 }}> <UserOps/> </Box>);
await expect(component).toHaveScreenshot();
});
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import { txnBatchData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect, devices } from 'playwright/lib';
import ZkEvmL2TxnBatch from './ZkEvmL2TxnBatch';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkEvmRollup) as any,
});
const batchNumber = '5';
const hooksConfig = {
router: {
query: { number: '5' },
query: { number: batchNumber },
},
};
const BATCH_API_URL = buildApiUrl('zkevm_l2_txn_batch', { number: '5' });
test.beforeEach(async({ mockTextAd, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.zkEvmRollup);
await mockTextAd();
await mockApiResponse('zkevm_l2_txn_batch', txnBatchData, { pathParams: { number: batchNumber } });
});
test('base view', async({ mount, page }) => {
test('base view', async({ render }) => {
test.slow();
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(BATCH_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchData),
}));
const component = await mount(
<TestApp>
<ZkEvmL2TxnBatch/>
</TestApp>,
{ hooksConfig },
);
const component = await render(<ZkEvmL2TxnBatch/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page }) => {
test('base view', async({ render }) => {
test.slow();
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(BATCH_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchData),
}));
const component = await mount(
<TestApp>
<ZkEvmL2TxnBatch/>
</TestApp>,
{ hooksConfig },
);
const component = await render(<ZkEvmL2TxnBatch/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
});
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { txnBatchesData } from 'mocks/zkEvm/txnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import ZkEvmL2TxnBatches from './ZkEvmL2TxnBatches';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkEvmRollup) as any,
});
const BATCHES_API_URL = buildApiUrl('zkevm_l2_txn_batches');
const BATCHES_COUNTERS_API_URL = buildApiUrl('zkevm_l2_txn_batches_count');
test('base view +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(BATCHES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txnBatchesData),
}));
await page.route(BATCHES_COUNTERS_API_URL, (route) => route.fulfill({
status: 200,
body: '9927',
}));
const component = await mount(
<TestApp>
<ZkEvmL2TxnBatches/>
</TestApp>,
);
test('base view +@mobile', async({ render, mockTextAd, mockEnvs, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.zkEvmRollup);
await mockTextAd();
await mockApiResponse('zkevm_l2_txn_batches', txnBatchesData);
await mockApiResponse('zkevm_l2_txn_batches_count', 9927);
const component = await render(<ZkEvmL2TxnBatches/>);
await expect(component).toHaveScreenshot();
});
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import * as zkSyncTxnBatchMock from 'mocks/zkSync/zkSyncTxnBatch';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect, devices } from 'playwright/lib';
import ZkSyncL2TxnBatch from './ZkSyncL2TxnBatch';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkSyncRollup) as any,
});
const batchNumber = String(zkSyncTxnBatchMock.base.number);
const hooksConfig = {
router: {
query: { number: String(zkSyncTxnBatchMock.base.number) },
query: { number: batchNumber },
},
};
test.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(BATCH_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(zkSyncTxnBatchMock.base),
}));
test.beforeEach(async({ mockTextAd, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.zkSyncRollup);
await mockTextAd();
await mockApiResponse('zksync_l2_txn_batch', zkSyncTxnBatchMock.base, { pathParams: { number: batchNumber } });
});
const BATCH_API_URL = buildApiUrl('zksync_l2_txn_batch', { number: String(zkSyncTxnBatchMock.base.number) });
test('base view', async({ mount }) => {
const component = await mount(
<TestApp>
<ZkSyncL2TxnBatch/>
</TestApp>,
{ hooksConfig },
);
test('base view', async({ render }) => {
const component = await render(<ZkSyncL2TxnBatch/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount }) => {
const component = await mount(
<TestApp>
<ZkSyncL2TxnBatch/>
</TestApp>,
{ hooksConfig },
);
test('base view', async({ render }) => {
const component = await render(<ZkSyncL2TxnBatch/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
});
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as zkSyncTxnBatchesMock from 'mocks/zkSync/zkSyncTxnBatches';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import ZkSyncL2TxnBatches from './ZkSyncL2TxnBatches';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.zkSyncRollup) as any,
});
const BATCHES_API_URL = buildApiUrl('zksync_l2_txn_batches');
const BATCHES_COUNTERS_API_URL = buildApiUrl('zksync_l2_txn_batches_count');
test('base view +@mobile', async({ mount, page }) => {
test('base view +@mobile', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
test.slow();
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(BATCHES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(zkSyncTxnBatchesMock.baseResponse),
}));
await page.route(BATCHES_COUNTERS_API_URL, (route) => route.fulfill({
status: 200,
body: '9927',
}));
const component = await mount(
<TestApp>
<ZkSyncL2TxnBatches/>
</TestApp>,
);
await mockEnvs(ENVS_MAP.zkSyncRollup);
await mockTextAd();
await mockApiResponse('zksync_l2_txn_batches', zkSyncTxnBatchesMock.baseResponse);
await mockApiResponse('zksync_l2_txn_batches_count', 9927);
const component = await render(<ZkSyncL2TxnBatches/>);
await expect(component).toHaveScreenshot();
});
......@@ -7,9 +7,10 @@ export interface Props {
text: string;
className?: string;
isLoading?: boolean;
onClick?: (event: React.MouseEvent) => void;
}
const CopyToClipboard = ({ text, className, isLoading }: Props) => {
const CopyToClipboard = ({ text, className, isLoading, onClick }: Props) => {
const { hasCopied, onCopy } = useClipboard(text, 1000);
const [ copied, setCopied ] = useState(false);
// have to implement controlled tooltip because of the issue - https://github.com/chakra-ui/chakra-ui/issues/7107
......@@ -24,6 +25,11 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
}
}, [ hasCopied ]);
const handleClick = React.useCallback((event: React.MouseEvent) => {
onCopy();
onClick?.(event);
}, [ onClick, onCopy ]);
if (isLoading) {
return <Skeleton boxSize={ 5 } className={ className } borderRadius="sm" flexShrink={ 0 } ml={ 2 } display="inline-block"/>;
}
......@@ -39,7 +45,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
variant="simple"
display="inline-block"
flexShrink={ 0 }
onClick={ onCopy }
onClick={ handleClick }
className={ className }
onMouseEnter={ onOpen }
onMouseLeave={ onClose }
......
......@@ -16,23 +16,21 @@ const EmptySearchResult = ({ text }: Props) => {
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
mt="50px"
>
<Icon as={ emptySearchResultIcon } boxSize={ 60 }/>
<Icon
as={ emptySearchResultIcon }
w={{ base: '160px', sm: '240px' }}
h="auto"
mb={{ base: 4, sm: 6 }}
/>
<Heading
as="h3"
marginBottom={ 2 }
fontSize={{ base: '2xl', sm: '3xl' }}
fontWeight="semibold"
>
<Heading as="h4" size="sm" mb={ 2 }>
No results
</Heading>
<Text
fontSize={{ base: 'sm' }}
variant="secondary"
align="center"
>
<Text fontSize={{ base: 'sm', sm: 'md' }} align="center">
{ text }
</Text>
</Box>
......
import type { ThemingProps } from '@chakra-ui/react';
import { Flex, chakra, useDisclosure, Popover, PopoverTrigger, PopoverContent, PopoverBody, Box } from '@chakra-ui/react';
import React from 'react';
import type { UserTags } from 'types/api/addressParams';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag';
interface TagData {
label: string;
display_name: string;
colorScheme?: ThemingProps<'Tag'>['colorScheme'];
variant?: ThemingProps<'Tag'>['variant'];
}
interface Props {
className?: string;
data?: UserTags;
isLoading?: boolean;
tagsBefore?: Array<TagData | undefined>;
tagsAfter?: Array<TagData | undefined>;
contentAfter?: React.ReactNode;
}
const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoading, contentAfter }: Props) => {
const isMobile = useIsMobile();
const { isOpen, onToggle, onClose } = useDisclosure();
const tags: Array<TagData> = [
...tagsBefore,
...(data?.private_tags || []),
...(data?.public_tags || []),
...(data?.watchlist_names || []),
...tagsAfter,
]
.filter(Boolean);
const metaSuitesPlaceholder = config.features.metasuites.isEnabled ?
<Box display="none" id="meta-suites__address-tag" data-ready={ !isLoading }/> :
null;
if (tags.length === 0 && !contentAfter) {
return metaSuitesPlaceholder;
}
const content = (() => {
if (isMobile && tags.length > 2) {
return (
<>
{
tags
.slice(0, 2)
.map((tag) => (
<Tag
key={ tag.label }
isLoading={ isLoading }
isTruncated
maxW={{ base: '115px', lg: 'initial' }}
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
))
}
{ metaSuitesPlaceholder }
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag isLoading={ isLoading }onClick={ onToggle }>+{ tags.length - 1 }</Tag>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<Flex columnGap={ 2 } rowGap={ 2 } flexWrap="wrap">
{
tags
.slice(2)
.map((tag) => (
<Tag
key={ tag.label }
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
))
}
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}
return (
<>
{ tags.map((tag) => (
<Tag
key={ tag.label }
isLoading={ isLoading }
isTruncated
maxW={{ base: '115px', lg: 'initial' }}
colorScheme={ 'colorScheme' in tag ? tag.colorScheme : 'gray' }
variant={ 'variant' in tag ? tag.variant : 'subtle' }
>
{ tag.display_name }
</Tag>
)) }
{ metaSuitesPlaceholder }
</>
);
})();
return (
<Flex className={ className } columnGap={ 2 } rowGap={ 2 } flexWrap="wrap" alignItems="center" flexGrow={ 1 }>
{ content }
{ contentAfter }
</Flex>
);
};
export default React.memo(chakra(EntityTags));
import { Box } from '@chakra-ui/react';
import React from 'react';
import * as addressMetadataMock from 'mocks/metadata/address';
import { test, expect } from 'playwright/lib';
import EntityTag from './EntityTag';
test.use({ viewport: { width: 400, height: 300 } });
test('custom name tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.customNameTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('generic tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.genericTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('protocol tag +@dark-mode', async({ render }) => {
const component = await render(<Box w="200px"><EntityTag data={ addressMetadataMock.protocolTag }/></Box>);
await expect(component).toHaveScreenshot();
});
test('tag with link and long name +@dark-mode', async({ render }) => {
const component = await render(<EntityTag data={ addressMetadataMock.infoTagWithLink } truncate/>);
await expect(component).toHaveScreenshot();
});
test('tag with tooltip +@dark-mode', async({ render, page, mockAssetResponse }) => {
await mockAssetResponse(addressMetadataMock.tagWithTooltip.meta?.tooltipIcon as string, './playwright/mocks/image_s.jpg');
const component = await render(<EntityTag data={ addressMetadataMock.tagWithTooltip }/>);
await component.getByText('BlockscoutHeroes').hover();
await page.getByText('Blockscout team member').waitFor({ state: 'visible' });
await expect(page).toHaveScreenshot();
});
import { chakra, Skeleton, Tag } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag as TEntityTag } from './types';
import IconSvg from 'ui/shared/IconSvg';
import TruncatedValue from 'ui/shared/TruncatedValue';
import EntityTagLink from './EntityTagLink';
import EntityTagPopover from './EntityTagPopover';
interface Props {
data: TEntityTag;
isLoading?: boolean;
truncate?: boolean;
}
const EntityTag = ({ data, isLoading, truncate }: Props) => {
if (isLoading) {
return <Skeleton borderRadius="sm" w="100px" h="24px"/>;
}
// const hasLink = Boolean(data.meta?.tagUrl || data.tagType === 'generic' || data.tagType === 'protocol');
// Change the condition when "Tag search" page is ready - issue #1869
const hasLink = Boolean(data.meta?.tagUrl);
const iconColor = data.meta?.textColor ?? 'gray.400';
return (
<EntityTagPopover data={ data }>
<Tag
display="flex"
alignItems="center"
minW={ 0 }
maxW={ truncate ? { base: '125px', lg: '300px' } : undefined }
bg={ data.meta?.bgColor }
color={ data.meta?.textColor }
colorScheme={ hasLink ? 'gray-blue' : 'gray' }
_hover={ hasLink ? { opacity: 0.76 } : undefined }
>
<EntityTagLink data={ data }>
{ data.tagType === 'name' && <IconSvg name="publictags_slim" boxSize={ 3 } mr={ 1 } flexShrink={ 0 } color={ iconColor }/> }
{ (data.tagType === 'protocol' || data.tagType === 'generic') && <chakra.span color={ iconColor } whiteSpace="pre"># </chakra.span> }
<TruncatedValue value={ data.name } tooltipPlacement="top"/>
</EntityTagLink>
</Tag>
</EntityTagPopover>
);
};
export default React.memo(EntityTag);
import type { LinkProps } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag } from './types';
import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from 'ui/shared/LinkExternal';
// import { route } from 'nextjs-routes';
// import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
data: EntityTag;
children: React.ReactNode;
}
const EntityTagLink = ({ data, children }: Props) => {
const handleLinkClick = React.useCallback(() => {
if (!data.meta?.tagUrl) {
return;
}
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, {
Type: 'Address tag',
Info: data.slug,
URL: data.meta.tagUrl,
});
}, [ data.meta?.tagUrl, data.slug ]);
const linkProps: LinkProps = {
color: 'inherit',
display: 'inline-flex',
overflow: 'hidden',
_hover: { textDecor: 'none', color: 'inherit' },
onClick: handleLinkClick,
};
// Uncomment this block when "Tag search" page is ready - issue #1869
// switch (data.tagType) {
// case 'generic':
// case 'protocol': {
// return (
// <LinkInternal
// { ...linkProps }
// href={ route({ pathname: '/' }) }
// >
// { children }
// </LinkInternal>
// );
// }
// }
if (data.meta?.tagUrl) {
return (
<LinkExternal
{ ...linkProps }
href={ data.meta.tagUrl }
iconColor={ data.meta.textColor }
>
{ children }
</LinkExternal>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
export default React.memo(EntityTagLink);
import { chakra, Image, Flex, Popover, PopoverArrow, PopoverBody, PopoverContent, PopoverTrigger, useColorModeValue, DarkMode } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag } from './types';
import makePrettyLink from 'lib/makePrettyLink';
import * as mixpanel from 'lib/mixpanel/index';
import LinkExternal from 'ui/shared/LinkExternal';
interface Props {
data: EntityTag;
children: React.ReactNode;
}
const EntityTagPopover = ({ data, children }: Props) => {
const bgColor = useColorModeValue('gray.700', 'gray.900');
const link = makePrettyLink(data.meta?.tooltipUrl);
const hasPopover = Boolean(data.meta?.tooltipIcon || data.meta?.tooltipTitle || data.meta?.tooltipDescription || data.meta?.tooltipUrl);
const handleLinkClick = React.useCallback(() => {
if (!data.meta?.tooltipUrl) {
return;
}
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, {
Type: 'Address tag',
Info: data.slug,
URL: data.meta.tooltipUrl,
});
}, [ data.meta?.tooltipUrl, data.slug ]);
if (!hasPopover) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
}
return (
<Popover trigger="hover" isLazy>
<PopoverTrigger>
{ children }
</PopoverTrigger>
<PopoverContent bgColor={ bgColor } borderRadius="sm">
<PopoverArrow bgColor={ bgColor }/>
<DarkMode>
<PopoverBody color="white" p={ 2 } fontSize="sm" display="flex" flexDir="column" rowGap={ 2 }>
{ (data.meta?.tooltipIcon || data.meta?.tooltipTitle) && (
<Flex columnGap={ 3 } alignItems="center">
{ data.meta?.tooltipIcon && <Image src={ data.meta.tooltipIcon } boxSize="30px" alt={ `${ data.name } tag logo` }/> }
{ data.meta?.tooltipTitle && <chakra.span fontWeight="600">{ data.meta.tooltipTitle }</chakra.span> }
</Flex>
) }
{ data.meta?.tooltipDescription && <chakra.span>{ data.meta.tooltipDescription }</chakra.span> }
{ link && <LinkExternal href={ link.url } onClick={ handleLinkClick }>{ link.domain }</LinkExternal> }
</PopoverBody>
</DarkMode>
</PopoverContent>
</Popover>
);
};
export default React.memo(EntityTagPopover);
import { Box, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, chakra } from '@chakra-ui/react';
import React from 'react';
import type { EntityTag as TEntityTag } from './types';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag';
import EntityTag from './EntityTag';
interface Props {
className?: string;
tags: Array<TEntityTag>;
isLoading?: boolean;
}
const EntityTags = ({ tags, className, isLoading }: Props) => {
const isMobile = useIsMobile();
const visibleNum = isMobile ? 2 : 3;
const metaSuitesPlaceholder = config.features.metasuites.isEnabled ?
<Box display="none" id="meta-suites__address-tag" data-ready={ !isLoading }/> :
null;
if (tags.length === 0) {
return metaSuitesPlaceholder;
}
const content = (() => {
if (tags.length > visibleNum) {
return (
<>
{ tags.slice(0, visibleNum).map((tag) => <EntityTag key={ tag.slug } data={ tag } isLoading={ isLoading } truncate/>) }
{ metaSuitesPlaceholder }
<Popover trigger="click" placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag isLoading={ isLoading } cursor="pointer" as="button" _hover={{ color: 'link_hovered' }}>
+{ tags.length - visibleNum }
</Tag>
</PopoverTrigger>
<PopoverContent w="300px">
<PopoverBody >
<Flex columnGap={ 2 } rowGap={ 2 } flexWrap="wrap">
{ tags.slice(visibleNum).map((tag) => <EntityTag key={ tag.slug } data={ tag }/>) }
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
}
return (
<>
{ tags.map((tag) => <EntityTag key={ tag.slug } data={ tag } isLoading={ isLoading } truncate/>) }
{ metaSuitesPlaceholder }
</>
);
})();
return (
<Flex className={ className } columnGap={ 2 } rowGap={ 2 } flexWrap="wrap" alignItems="center" flexGrow={ 1 }>
{ content }
</Flex>
);
};
export default React.memo(chakra(EntityTags));
import type { EntityTag } from './types';
import type { UserTags } from 'types/api/addressParams';
export default function formatUserTags(data: UserTags | undefined): Array<EntityTag> {
return [
...(data?.private_tags || []).map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'private_tag' as const, ordinal: 1_000 })),
...(data?.watchlist_names || []).map((tag) => ({ slug: tag.label, name: tag.display_name, tagType: 'watchlist' as const, ordinal: 1_000 })),
];
}
import type { EntityTag } from './types';
export default function sortEntityTags(tagA: EntityTag, tagB: EntityTag): number {
if (tagA.ordinal < tagB.ordinal) {
return 1;
}
if (tagA.ordinal > tagB.ordinal) {
return -1;
}
return 0;
}
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
import type { AddressMetadataTagFormatted } from 'types/client/addressMetadata';
export type EntityTagType = AddressMetadataTagType | 'custom' | 'watchlist' | 'private_tag';
export interface EntityTag extends Pick<AddressMetadataTagFormatted, 'slug' | 'name' | 'ordinal'> {
tagType: EntityTagType;
meta?: AddressMetadataTagFormatted['meta'];
}
import type { ChakraProps } from '@chakra-ui/react';
import type { ChakraProps, LinkProps } from '@chakra-ui/react';
import { Link, chakra, Box, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
......@@ -10,10 +10,11 @@ interface Props {
children: React.ReactNode;
isLoading?: boolean;
variant?: 'subtle';
onClick?: () => void;
iconColor?: LinkProps['color'];
onClick?: LinkProps['onClick'];
}
const LinkExternal = ({ href, children, className, isLoading, variant, onClick }: Props) => {
const LinkExternal = ({ href, children, className, isLoading, variant, iconColor, onClick }: Props) => {
const subtleLinkBg = useColorModeValue('gray.100', 'gray.700');
const styleProps: ChakraProps = (() => {
......@@ -60,7 +61,7 @@ const LinkExternal = ({ href, children, className, isLoading, variant, onClick }
return (
<Link className={ className } { ...styleProps } target="_blank" href={ href } onClick={ onClick }>
{ children }
<IconSvg name="arrows/north-east" boxSize={ 4 } verticalAlign="middle" color="gray.400" flexShrink={ 0 }/>
<IconSvg name="arrows/north-east" boxSize={ 4 } verticalAlign="middle" color={ iconColor ?? 'gray.400' } flexShrink={ 0 }/>
</Link>
);
};
......
......@@ -5,7 +5,7 @@ import type { TokenInfo } from 'types/api/token';
import * as addressMock from 'mocks/address/address';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags';
import EntityTags from 'ui/shared/EntityTags/EntityTags';
import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
......@@ -34,8 +34,8 @@ const DefaultView = () => {
<>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer"/>
<EntityTags
tagsBefore={ [
{ label: 'example', display_name: 'Example label' },
tags={ [
{ slug: 'example', name: 'Example label', tagType: 'custom' },
] }
flexGrow={ 1 }
/>
......
......@@ -5,7 +5,8 @@ import type { TokenInfo } from 'types/api/token';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EntityTags from 'ui/shared/EntityTags';
import EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
......@@ -29,21 +30,19 @@ const LongNameAndManyTags = () => {
<>
<IconSvg name="verified_token" color="green.500" boxSize={ 6 } cursor="pointer" flexShrink={ 0 }/>
<EntityTags
data={{
tags={ [
{ slug: 'example', name: 'Example with long name', tagType: 'custom' },
...formatUserTags({
private_tags: [ privateTag ],
public_tags: [ publicTag ],
watchlist_names: [ watchlistName ],
}}
tagsBefore={ [
{ label: 'example', display_name: 'Example with long name' },
}),
{ slug: 'after_1', name: 'Another tag', tagType: 'custom' },
{ slug: 'after_2', name: 'And yet more', tagType: 'custom' },
] }
tagsAfter={ [
{ label: 'after_1', display_name: 'Another tag' },
{ label: 'after_2', display_name: 'And yet more' },
] }
contentAfter={ <NetworkExplorers type="token" pathParam="token-hash" ml="auto"/> }
flexGrow={ 1 }
/>
<NetworkExplorers type="token" pathParam="token-hash" ml="auto"/>
</>
);
......
......@@ -52,8 +52,11 @@ const TabsWithScroll = ({
}, [ tabs ]);
const handleTabChange = React.useCallback((index: number) => {
if (isLoading) {
return;
}
onTabChange ? onTabChange(index) : setActiveTabIndex(index);
}, [ onTabChange ]);
}, [ isLoading, onTabChange ]);
useEffect(() => {
if (defaultTabIndex !== undefined) {
......
import type { PlacementWithLogical } from '@chakra-ui/react';
import { Tooltip } from '@chakra-ui/react';
import debounce from 'lodash/debounce';
import React from 'react';
......@@ -8,9 +9,10 @@ import { BODY_TYPEFACE } from 'theme/foundations/typography';
interface Props {
children: React.ReactNode;
label: string;
placement?: PlacementWithLogical;
}
const TruncatedTextTooltip = ({ children, label }: Props) => {
const TruncatedTextTooltip = ({ children, label, placement }: Props) => {
const childRef = React.useRef<HTMLElement>(null);
const [ isTruncated, setTruncated ] = React.useState(false);
......@@ -60,7 +62,7 @@ const TruncatedTextTooltip = ({ children, label }: Props) => {
);
if (isTruncated) {
return <Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}>{ modifiedChildren }</Tooltip>;
return <Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }} placement={ placement }>{ modifiedChildren }</Tooltip>;
}
return modifiedChildren;
......
import type { PlacementWithLogical } from '@chakra-ui/react';
import { Skeleton, chakra } from '@chakra-ui/react';
import React from 'react';
......@@ -7,11 +8,12 @@ interface Props {
className?: string;
isLoading?: boolean;
value: string;
tooltipPlacement?: PlacementWithLogical;
}
const TruncatedValue = ({ className, isLoading, value }: Props) => {
const TruncatedValue = ({ className, isLoading, value, tooltipPlacement }: Props) => {
return (
<TruncatedTextTooltip label={ value }>
<TruncatedTextTooltip label={ value } placement={ tooltipPlacement }>
<Skeleton
className={ className }
isLoaded={ !isLoading }
......
import { Flex, chakra } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import React from 'react';
import { useAccount } from 'wagmi';
import useIsMobile from 'lib/hooks/useIsMobile';
import Web3ModalProvider from '../Web3ModalProvider';
import useAccount from 'lib/web3/useAccount';
const GetitAdPlugin = dynamic(() => import('getit-sdk').then(module => module.GetitAdPlugin), { ssr: false });
const GETIT_API_KEY = 'ZmGXVvwYUAW4yXL8RzWQHNKmpSyQmt3TDXsXUxqFqXPdoaiSSFyca3BOyunDcWdyOwTkX3UVVQel28qbjoOoWPxYVpPdNzbUNkAHyFyJX7Lk9TVcPDZKTQmwHlSMzO3a';
const GetitBannerContent = ({ address, className }: { address?: string; className?: string }) => {
const GetitBanner = ({ className }: { className?: string }) => {
const isMobile = Boolean(useIsMobile());
const { address } = useAccount();
return (
<Flex className={ className } h="90px">
......@@ -27,22 +26,4 @@ const GetitBannerContent = ({ address, className }: { address?: string; classNam
);
};
const GetitBannerWithWalletAddress = ({ className }: { className?: string }) => {
const { address } = useAccount();
return <GetitBannerContent address={ address } className={ className }/>;
};
const GetitBanner = ({ className }: { className?: string }) => {
const fallback = React.useCallback(() => {
return <GetitBannerContent className={ className }/>;
}, [ className ]);
return (
<Web3ModalProvider fallback={ fallback }>
<GetitBannerWithWalletAddress className={ className }/>
</Web3ModalProvider>
);
};
export default chakra(GetitBanner);
......@@ -2,15 +2,22 @@ import { Flex, chakra } from '@chakra-ui/react';
import { Banner, setWalletAddresses } from '@hypelab/sdk-react';
import Script from 'next/script';
import React from 'react';
import { useAccount } from 'wagmi';
import Web3ModalProvider from '../Web3ModalProvider';
import useAccount from 'lib/web3/useAccount';
import { hypeInit } from './hypeBannerScript';
const DESKTOP_BANNER_SLUG = 'b1559fc3e7';
const MOBILE_BANNER_SLUG = '668ed80a9e';
const HypeBannerContent = ({ className }: { className?: string }) => {
const HypeBanner = ({ className }: { className?: string }) => {
const { address } = useAccount();
React.useEffect(() => {
if (address) {
setWalletAddresses([ address ]);
}
}, [ address ]);
return (
<>
......@@ -28,28 +35,4 @@ const HypeBannerContent = ({ className }: { className?: string }) => {
);
};
const HypeBannerWithWalletAddress = ({ className }: { className?: string }) => {
const { address } = useAccount();
React.useEffect(() => {
if (address) {
setWalletAddresses([ address ]);
}
}, [ address ]);
return <HypeBannerContent className={ className }/>;
};
const HypeBanner = ({ className }: { className?: string }) => {
const fallback = React.useCallback(() => {
return <HypeBannerContent className={ className }/>;
}, [ className ]);
return (
<Web3ModalProvider fallback={ fallback }>
<HypeBannerWithWalletAddress className={ className }/>
</Web3ModalProvider>
);
};
export default chakra(HypeBanner);
......@@ -4,7 +4,7 @@ import React from 'react';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
interface Props extends TagProps {
export interface Props extends TagProps {
isLoading?: boolean;
}
......
......@@ -97,6 +97,18 @@ test('with ENS', async({ mount }) => {
await expect(component).toHaveScreenshot();
});
test('with name tag', async({ mount }) => {
const component = await mount(
<TestApp>
<AddressEntity
address={ addressMock.withNameTag }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('external link', async({ mount }) => {
const component = await mount(
<TestApp>
......
......@@ -100,11 +100,13 @@ const Icon = (props: IconProps) => {
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'address'>;
const Content = chakra((props: ContentProps) => {
if (props.address.name || props.address.ens_domain_name) {
const text = props.address.ens_domain_name || props.address.name;
const nameTag = props.address.metadata?.tags.find(tag => tag.tagType === 'name')?.name;
const nameText = nameTag || props.address.ens_domain_name || props.address.name;
if (nameText) {
const label = (
<VStack gap={ 0 } py={ 1 } color="inherit">
<Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ text }</Box>
<Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ nameText }</Box>
<Box whiteSpace="pre-wrap" wordBreak="break-word">{ props.address.hash }</Box>
</VStack>
);
......@@ -112,7 +114,7 @@ const Content = chakra((props: ContentProps) => {
return (
<Tooltip label={ label } maxW={{ base: '100vw', lg: '400px' }}>
<Skeleton isLoaded={ !props.isLoading } overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap" as="span">
{ text }
{ nameText }
</Skeleton>
</Tooltip>
);
......@@ -140,7 +142,7 @@ const Copy = (props: CopyProps) => {
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
address: Pick<AddressParam, 'hash' | 'name' | 'is_contract' | 'is_verified' | 'implementation_name' | 'ens_domain_name'>;
address: Pick<AddressParam, 'hash' | 'name' | 'is_contract' | 'is_verified' | 'implementation_name' | 'ens_domain_name' | 'metadata'>;
isSafeAddress?: boolean;
}
......
......@@ -11,7 +11,7 @@ const LayoutDefault = ({ children }: Props) => {
return (
<Layout.Container
overflowY="hidden"
height={ [ '-webkit-fill-available', '100vh' ] }
height="$100vh"
display="flex"
flexDirection="column"
>
......
import { Divider, Flex, Skeleton, VStack } from '@chakra-ui/react';
import React from 'react';
import Tag from 'ui/shared/chakra/Tag';
interface Props {
methodId: string;
methodCall: string;
isLoading?: boolean;
}
const Item = ({ label, text, isLoading }: { label: string; text: string; isLoading?: boolean}) => {
const Item = ({ label, children, isLoading }: { label: string; children: React.ReactNode; isLoading?: boolean}) => {
return (
<Flex
columnGap={ 5 }
......@@ -19,7 +21,7 @@ const Item = ({ label, text, isLoading }: { label: string; text: string; isLoadi
<Skeleton fontWeight={ 600 } w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } isLoaded={ !isLoading }>
{ label }
</Skeleton >
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">{ text }</Skeleton>
{ children }
</Flex>
);
};
......@@ -32,8 +34,12 @@ const LogDecodedInputDataHeader = ({ methodId, methodCall, isLoading }: Props) =
fontSize="sm"
lineHeight={ 5 }
>
<Item label="Method id" text={ methodId } isLoading={ isLoading }/>
<Item label="Call" text={ methodCall } isLoading={ isLoading }/>
<Item label="Method id" isLoading={ isLoading }>
<Tag isLoading={ isLoading }>{ methodId }</Tag>
</Item>
<Item label="Call" isLoading={ isLoading }>
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">{ methodCall }</Skeleton>
</Item>
</VStack>
);
};
......
......@@ -11,10 +11,6 @@ import WalletMenuMobile from 'ui/snippets/walletMenu/WalletMenuMobile';
import Burger from './Burger';
const LOGO_IMAGE_PROPS = {
margin: '0 auto',
};
type Props = {
hideSearchBar?: boolean;
renderSearchBar?: () => React.ReactNode;
......@@ -45,13 +41,12 @@ const HeaderMobile = ({ hideSearchBar, renderSearchBar }: Props) => {
bgColor={ bgColor }
width="100%"
alignItems="center"
justifyContent="space-between"
transitionProperty="box-shadow"
transitionDuration="slow"
boxShadow={ !inView && scrollDirection === 'down' ? 'md' : 'none' }
>
<Burger/>
<NetworkLogo imageProps={ LOGO_IMAGE_PROPS }/>
<NetworkLogo ml={ 2 } mr="auto"/>
<Flex columnGap={ 2 }>
{ config.features.account.isEnabled ? <ProfileMenuMobile/> : <Box boxSize={ 10 }/> }
{ config.features.blockchainInteraction.isEnabled && <WalletMenuMobile/> }
......
......@@ -20,16 +20,17 @@ type Props = {
px?: string | number;
className?: string;
onClick?: () => void;
disableActiveState?: boolean;
}
const NavLink = ({ item, isCollapsed, px, className, onClick }: Props) => {
const NavLink = ({ item, isCollapsed, px, className, onClick, disableActiveState }: Props) => {
const isMobile = useIsMobile();
const colors = useColors();
const isExpanded = isCollapsed === false;
const isInternalLink = isInternalItem(item);
const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive: isInternalLink && item.isActive });
const styleProps = useNavLinkStyleProps({ isCollapsed, isExpanded, isActive: isInternalLink && item.isActive && !disableActiveState });
const isXLScreen = useBreakpointValue({ base: false, xl: true });
const href = isInternalLink ? route(item.nextRoute) : item.url;
......
import type { StyleProps } from '@chakra-ui/react';
import { Box, Image, useColorModeValue, Skeleton } from '@chakra-ui/react';
import { Box, Image, useColorModeValue, Skeleton, chakra } from '@chakra-ui/react';
import React from 'react';
import { route } from 'nextjs-routes';
......@@ -10,10 +9,10 @@ import IconSvg from 'ui/shared/IconSvg';
interface Props {
isCollapsed?: boolean;
onClick?: (event: React.SyntheticEvent) => void;
imageProps?: StyleProps;
className?: string;
}
const LogoFallback = ({ isCollapsed, isSmall, imageProps }: { isCollapsed?: boolean; isSmall?: boolean; imageProps?: StyleProps }) => {
const LogoFallback = ({ isCollapsed, isSmall }: { isCollapsed?: boolean; isSmall?: boolean }) => {
const field = isSmall ? 'icon' : 'logo';
const logoColor = useColorModeValue('blue.600', 'white');
......@@ -38,12 +37,11 @@ const LogoFallback = ({ isCollapsed, isSmall, imageProps }: { isCollapsed?: bool
height="100%"
color={ logoColor }
display={ display }
{ ...imageProps }
/>
);
};
const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => {
const NetworkLogo = ({ isCollapsed, onClick, className }: Props) => {
const logoSrc = useColorModeValue(config.UI.sidebar.logo.default, config.UI.sidebar.logo.dark || config.UI.sidebar.logo.default);
const iconSrc = useColorModeValue(config.UI.sidebar.icon.default, config.UI.sidebar.icon.dark || config.UI.sidebar.icon.default);
......@@ -53,6 +51,7 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => {
return (
<Box
className={ className }
as="a"
href={ route({ pathname: '/' }) }
width={{ base: '120px', lg: isCollapsed === false ? '120px' : '30px', xl: isCollapsed ? '30px' : '120px' }}
......@@ -69,10 +68,9 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => {
h="100%"
src={ logoSrc }
alt={ `${ config.chain.name } network logo` }
fallback={ <LogoFallback isCollapsed={ isCollapsed } imageProps={ imageProps }/> }
fallback={ <LogoFallback isCollapsed={ isCollapsed }/> }
display={{ base: 'block', lg: isCollapsed === false ? 'block' : 'none', xl: isCollapsed ? 'none' : 'block' }}
style={ logoStyle }
{ ...imageProps }
/>
{ /* small logo */ }
<Image
......@@ -80,13 +78,12 @@ const NetworkLogo = ({ isCollapsed, onClick, imageProps }: Props) => {
h="100%"
src={ iconSrc }
alt={ `${ config.chain.name } network logo` }
fallback={ <LogoFallback isCollapsed={ isCollapsed } imageProps={ imageProps } isSmall/> }
fallback={ <LogoFallback isCollapsed={ isCollapsed } isSmall/> }
display={{ base: 'none', lg: isCollapsed === false ? 'none' : 'block', xl: isCollapsed ? 'block' : 'none' }}
style={ iconStyle }
{ ...imageProps }
/>
</Box>
);
};
export default React.memo(NetworkLogo);
export default React.memo(chakra(NetworkLogo));
......@@ -45,10 +45,12 @@ const NetworkMenuContentMobile = ({ items, tabs }: Props) => {
</>
) : (
<>
<Select size="xs" borderRadius="base" value={ selectedTab } onChange={ handleSelectChange } focusBorderColor="none">
{ tabs.length > 1 && (
<Select size="xs" borderRadius="base" value={ selectedTab } onChange={ handleSelectChange } focusBorderColor="none" mb={ 6 }>
{ tabs.map((tab) => <option key={ tab } value={ tab }>{ capitalize(tab) }</option>) }
</Select>
<VStack as="ul" spacing={ 2 } alignItems="stretch" mt={ 6 }>
) }
<VStack as="ul" spacing={ 2 } alignItems="stretch">
{ items
.filter(({ group }) => group === selectedTab)
.map((network) => (
......
import { Box, Button, Text, VStack, useColorModeValue } from '@chakra-ui/react';
import { Box, Button, VStack, chakra } from '@chakra-ui/react';
import React from 'react';
import type { UserInfo } from 'types/api/account';
......@@ -18,7 +18,6 @@ type Props = {
const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => {
const { accountNavItems, profileItem } = useNavItems();
const primaryTextColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
const handleSingOutClick = React.useCallback(() => {
mixpanel.logEvent(
......@@ -32,37 +31,28 @@ const ProfileMenuContent = ({ data, onNavLinkClick }: Props) => {
return null;
}
const userName = data?.email || data?.nickname || data?.name;
return (
<Box>
{ (data?.name || data?.nickname) && (
<Text
{ userName && (
<Box
fontSize="sm"
fontWeight={ 500 }
color={ primaryTextColor }
{ ...getDefaultTransitionProps() }
>
Signed in as { data.name || data.nickname }
</Text>
) }
{ data?.email && (
<Text
fontSize="sm"
mb={ 1 }
fontWeight={ 500 }
color="gray.500"
{ ...getDefaultTransitionProps() }
>
{ data.email }
</Text>
<span>Signed in as </span>
<chakra.span color="text_secondary">{ userName }</chakra.span>
</Box>
) }
<NavLink item={ profileItem } isActive={ undefined } px="0px" isCollapsed={ false } onClick={ onNavLinkClick }/>
<NavLink item={ profileItem } disableActiveState={ true } px="0px" isCollapsed={ false } onClick={ onNavLinkClick }/>
<Box as="nav" mt={ 2 } pt={ 2 } borderTopColor="divider" borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<VStack as="ul" spacing="0" alignItems="flex-start" overflow="hidden">
{ accountNavItems.map((item) => (
<NavLink
key={ item.text }
item={ item }
isActive={ undefined }
disableActiveState={ true }
isCollapsed={ false }
px="0px"
onClick={ onNavLinkClick }
......
......@@ -97,7 +97,7 @@ const ProfileMenuDesktop = ({ isHomePage, className, fallbackIconSize }: Props)
</Box>
</Tooltip>
{ hasMenu && (
<PopoverContent w="212px">
<PopoverContent maxW="400px" minW="220px" w="min-content">
<PopoverBody padding="24px 16px 16px 16px">
<ProfileMenuContent data={ data }/>
</PopoverBody>
......
......@@ -66,7 +66,7 @@ const ProfileMenuMobile = () => {
autoFocus={ false }
>
<DrawerOverlay/>
<DrawerContent maxWidth="260px">
<DrawerContent maxWidth="300px">
<DrawerBody p={ 6 }>
<ProfileMenuContent data={ data } onNavLinkClick={ onClose }/>
</DrawerBody>
......
import { Box, Button, Text, Flex } from '@chakra-ui/react';
import { Box, Button, Text, Flex, IconButton } from '@chakra-ui/react';
import React from 'react';
import * as mixpanel from 'lib/mixpanel/index';
......@@ -12,15 +12,24 @@ type Props = {
address?: string;
disconnect?: () => void;
isAutoConnectDisabled?: boolean;
openWeb3Modal: () => void;
closeWalletMenu: () => void;
};
const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled }: Props) => {
const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled, openWeb3Modal, closeWalletMenu }: Props) => {
const { themedBackgroundOrange } = useMenuButtonColors();
const [ isModalOpening, setIsModalOpening ] = React.useState(false);
const onAddressClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Address click' });
}, []);
const handleOpenWeb3Modal = React.useCallback(async() => {
setIsModalOpening(true);
await openWeb3Modal();
setTimeout(closeWalletMenu, 300);
}, [ openWeb3Modal, closeWalletMenu ]);
return (
<Box>
{ isAutoConnectDisabled && (
......@@ -60,6 +69,7 @@ const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled }: Props
>
Your wallet is used to interact with apps and contracts in the explorer.
</Text>
<Flex alignItems="center" mb={ 6 }>
<AddressEntity
address={{ hash: address }}
noTooltip
......@@ -67,9 +77,20 @@ const WalletMenuContent = ({ address, disconnect, isAutoConnectDisabled }: Props
fontSize="sm"
fontWeight={ 700 }
color="text"
mb={ 6 }
onClick={ onAddressClick }
flex={ 1 }
/>
<IconButton
aria-label="open wallet"
icon={ <IconSvg name="gear_slim" boxSize={ 5 }/> }
variant="simple"
h="20px"
w="20px"
ml={ 1 }
onClick={ handleOpenWeb3Modal }
isLoading={ isModalOpening }
/>
</Flex>
<Button size="sm" width="full" variant="outline" onClick={ disconnect }>
Disconnect
</Button>
......
......@@ -20,7 +20,7 @@ type Props = {
};
const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => {
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet({ source: 'Header' });
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen, openModal } = useWallet({ source: 'Header' });
const { themedBackground, themedBackgroundOrange, themedBorderColor, themedColor } = useMenuButtonColors();
const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false);
const isMobile = useIsMobile();
......@@ -82,7 +82,7 @@ const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => {
variant={ variant }
colorScheme="blue"
flexShrink={ 0 }
isLoading={ isModalOpening || isModalOpen }
isLoading={ (isModalOpening || isModalOpen) && !isWalletConnected }
loadingText="Connect wallet"
onClick={ isWalletConnected ? openPopover : connect }
fontSize="sm"
......@@ -102,7 +102,13 @@ const WalletMenuDesktop = ({ isHomePage, className, size = 'md' }: Props) => {
{ isWalletConnected && (
<PopoverContent w="235px">
<PopoverBody padding="24px 16px 16px 16px">
<WalletMenuContent address={ address } disconnect={ disconnect } isAutoConnectDisabled={ isAutoConnectDisabled }/>
<WalletMenuContent
address={ address }
disconnect={ disconnect }
isAutoConnectDisabled={ isAutoConnectDisabled }
openWeb3Modal={ openModal }
closeWalletMenu={ setIsPopoverOpen.off }
/>
</PopoverBody>
</PopoverContent>
) }
......
......@@ -14,7 +14,7 @@ import WalletTooltip from './WalletTooltip';
const WalletMenuMobile = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet({ source: 'Header' });
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen, openModal } = useWallet({ source: 'Header' });
const { themedBackground, themedBackgroundOrange, themedBorderColor, themedColor } = useMenuButtonColors();
const isMobile = useIsMobile();
const { isAutoConnectDisabled } = useMarketplaceContext();
......@@ -48,7 +48,7 @@ const WalletMenuMobile = () => {
color={ themedColor }
borderColor={ !isWalletConnected ? themedBorderColor : undefined }
onClick={ isWalletConnected ? openPopover : connect }
isLoading={ isModalOpening || isModalOpen }
isLoading={ (isModalOpening || isModalOpen) && !isWalletConnected }
/>
</WalletTooltip>
{ isWalletConnected && (
......@@ -61,7 +61,13 @@ const WalletMenuMobile = () => {
<DrawerOverlay/>
<DrawerContent maxWidth="260px">
<DrawerBody p={ 6 }>
<WalletMenuContent address={ address } disconnect={ disconnect } isAutoConnectDisabled={ isAutoConnectDisabled }/>
<WalletMenuContent
address={ address }
disconnect={ disconnect }
isAutoConnectDisabled={ isAutoConnectDisabled }
openWeb3Modal={ openModal }
closeWalletMenu={ onClose }
/>
</DrawerBody>
</DrawerContent>
</Drawer>
......
......@@ -45,6 +45,7 @@ export default function useWallet({ source }: Params) {
const isWalletConnected = isClientLoaded && !isDisconnected && address !== undefined;
return {
openModal: open,
isWalletConnected,
address: address || '',
connect: handleConnect,
......
import { Box, Flex, Tooltip } from '@chakra-ui/react';
import { Box, Flex, Tooltip, useToken } 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 type { EntityTag } from 'ui/shared/EntityTags/types';
import config from 'configs/app';
import useAddressMetadataInfoQuery from 'lib/address/useAddressMetadataInfoQuery';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
......@@ -14,7 +16,9 @@ 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 EntityTags from 'ui/shared/EntityTags/EntityTags';
import formatUserTags from 'ui/shared/EntityTags/formatUserTags';
import sortEntityTags from 'ui/shared/EntityTags/sortEntityTags';
import IconSvg from 'ui/shared/IconSvg';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -24,9 +28,10 @@ import TokenVerifiedInfo from './TokenVerifiedInfo';
interface Props {
tokenQuery: UseQueryResult<TokenInfo, ResourceError<unknown>>;
addressQuery: UseQueryResult<Address, ResourceError<unknown>>;
hash: string;
}
const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
const TokenPageTitle = ({ tokenQuery, addressQuery, hash }: Props) => {
const appProps = useAppContext();
const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address || '') : '';
......@@ -35,9 +40,12 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
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 addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]);
const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery);
const isLoading = tokenQuery.isPlaceholderData ||
addressQuery.isPlaceholderData ||
(config.features.verifiedTokens.isEnabled && verifiedInfoQuery.isPending);
const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : '';
......@@ -54,6 +62,37 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
};
}, [ appProps.referrer ]);
const bridgedTokenTagBgColor = useToken('colors', 'blue.500');
const bridgedTokenTagTextColor = useToken('colors', 'white');
const tags: Array<EntityTag> = React.useMemo(() => {
return [
tokenQuery.data ? { slug: tokenQuery.data?.type, name: tokenQuery.data?.type, tagType: 'custom' as const, ordinal: -20 } : undefined,
config.features.bridgedTokens.isEnabled && tokenQuery.data?.is_bridged ?
{
slug: 'bridged',
name: 'Bridged',
tagType: 'custom' as const,
ordinal: -10,
meta: { bgColor: bridgedTokenTagBgColor, textColor: bridgedTokenTagTextColor },
} :
undefined,
...formatUserTags(addressQuery.data),
verifiedInfoQuery.data?.projectSector ?
{ slug: verifiedInfoQuery.data.projectSector, name: verifiedInfoQuery.data.projectSector, tagType: 'custom' as const, ordinal: -30 } :
undefined,
...(addressMetadataQuery.data?.addresses?.[hash.toLowerCase()]?.tags || []),
].filter(Boolean).sort(sortEntityTags);
}, [
addressMetadataQuery.data?.addresses,
addressQuery.data,
bridgedTokenTagBgColor,
bridgedTokenTagTextColor,
tokenQuery.data,
verifiedInfoQuery.data?.projectSector,
hash,
]);
const contentAfter = (
<>
{ verifiedInfoQuery.data?.tokenAddress && (
......@@ -64,19 +103,8 @@ const TokenPageTitle = ({ tokenQuery, addressQuery }: Props) => {
</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
}
isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) }
tags={ tags }
flexGrow={ 1 }
/>
</>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as txMock from 'mocks/txs/tx';
import { txInterpretation } from 'mocks/txs/txInterpretation';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TxSubHeading from './TxSubHeading';
import type { TxQuery } from './useTxQuery';
const hash = '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193';
const TX_INTERPRETATION_API_URL = buildApiUrl('tx_interpretation', { hash });
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
test('no interpretation +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>
</TestApp>,
);
test('no interpretation +@mobile', async({ render }) => {
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
await expect(component).toHaveScreenshot();
});
const bsInterpretationTest = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.txInterpretation) as any,
});
bsInterpretationTest('with interpretation +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txInterpretation),
}));
const component = await mount(
<TestApp>
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>
</TestApp>,
);
test.describe('blockscout provider', () => {
test.beforeEach(async({ mockEnvs }) => {
await mockEnvs(ENVS_MAP.txInterpretation);
});
test('with interpretation +@mobile +@dark-mode', async({ render, mockApiResponse }) => {
await mockApiResponse('tx_interpretation', txInterpretation, { pathParams: { hash } });
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
await expect(component).toHaveScreenshot();
});
bsInterpretationTest('with interpretation and view all link +@mobile', async({ mount, page }) => {
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ data: { summaries: [ ...txInterpretation.data.summaries, ...txInterpretation.data.summaries ] } }),
}));
});
const component = await mount(
<TestApp>
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>
</TestApp>,
test('with interpretation and view all link +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse(
'tx_interpretation',
{ data: { summaries: [ ...txInterpretation.data.summaries, ...txInterpretation.data.summaries ] } },
{ pathParams: { hash } },
);
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
await expect(component).toHaveScreenshot();
});
bsInterpretationTest('no interpretation, has method called', async({ mount, page }) => {
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ data: { summaries: [] } }),
}));
const component = await mount(
<TestApp>
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>
</TestApp>,
);
});
test('no interpretation, has method called', async({ render, mockApiResponse }) => {
await mockApiResponse('tx_interpretation', { data: { summaries: [] } }, { pathParams: { hash } });
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txQuery }/>);
await expect(component).toHaveScreenshot();
});
});
bsInterpretationTest('no interpretation', async({ mount, page }) => {
test('no interpretation', async({ render, mockApiResponse }) => {
const txPendingQuery = {
data: txMock.pending,
isPlaceholderData: false,
isError: false,
} as TxQuery;
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ data: { summaries: [] } }),
}));
const component = await mount(
<TestApp>
<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txPendingQuery }/>
</TestApp>,
);
await mockApiResponse('tx_interpretation', { data: { summaries: [] } }, { pathParams: { hash } });
const component = await render(<TxSubHeading hash={ hash } hasTag={ false } txQuery={ txPendingQuery }/>);
await expect(component).toHaveScreenshot();
});
});
import { Box } from '@chakra-ui/react';
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { OptimisticL2WithdrawalStatus } from 'types/api/optimisticL2';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as configs from 'playwright/utils/configs';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TxDetailsWithdrawalStatus from './TxDetailsWithdrawalStatus';
......@@ -16,20 +14,13 @@ const statuses: Array<OptimisticL2WithdrawalStatus> = [
'Relayed',
];
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
statuses.forEach((status) => {
test(`status="${ status }"`, async({ mount }) => {
const component = await mount(
<TestApp>
test(`status="${ status }"`, async({ render, mockEnvs }) => {
await mockEnvs(ENVS_MAP.optimisticRollup);
const component = await render(
<Box p={ 2 }>
<TxDetailsWithdrawalStatus status={ status } l1TxHash="0x7d93a59a228e97d084a635181c3053e324237d07566ec12287eae6da2bcf9456"/>
</Box>
</TestApp>,
</Box>,
);
await expect(component).toHaveScreenshot();
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as txMock from 'mocks/txs/tx';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import * as configs from 'playwright/utils/configs';
import TxInfo from './TxInfo';
const hooksConfig = {
router: {
query: { hash: 1 },
},
};
test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.base } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('between addresses +@mobile +@dark-mode', async({ render, page }) => {
const component = await render(<TxInfo data={ txMock.base } isLoading={ false }/>);
await page.getByText('View details').click();
......@@ -30,13 +18,8 @@ test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
});
});
test('creating contact', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.withContractCreation } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('creating contact', async({ render, page }) => {
const component = await render(<TxInfo data={ txMock.withContractCreation } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
......@@ -44,13 +27,8 @@ test('creating contact', async({ mount, page }) => {
});
});
test('with token transfer +@mobile', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('with token transfer +@mobile', async({ render, page }) => {
const component = await render(<TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
......@@ -58,13 +36,8 @@ test('with token transfer +@mobile', async({ mount, page }) => {
});
});
test('with decoded revert reason', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.withDecodedRevertReason } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('with decoded revert reason', async({ render, page }) => {
const component = await render(<TxInfo data={ txMock.withDecodedRevertReason } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
......@@ -72,13 +45,8 @@ test('with decoded revert reason', async({ mount, page }) => {
});
});
test('with decoded raw reason', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.withRawRevertReason } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('with decoded raw reason', async({ render, page }) => {
const component = await render(<TxInfo data={ txMock.withRawRevertReason } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
......@@ -86,13 +54,8 @@ test('with decoded raw reason', async({ mount, page }) => {
});
});
test('pending', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.pending } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('pending', async({ render, page }) => {
const component = await render(<TxInfo data={ txMock.pending } isLoading={ false }/>);
await page.getByText('View details').click();
......@@ -102,13 +65,8 @@ test('pending', async({ mount, page }) => {
});
});
test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.withActionsUniswap } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('with actions uniswap +@mobile +@dark-mode', async({ render, page }) => {
const component = await render(<TxInfo data={ txMock.withActionsUniswap } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
......@@ -116,13 +74,8 @@ test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => {
});
});
test('with blob', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.withBlob } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('with blob', async({ render, page }) => {
const component = await render(<TxInfo data={ txMock.withBlob } isLoading={ false }/>);
await page.getByText('View details').click();
......@@ -132,39 +85,20 @@ test('with blob', async({ mount, page }) => {
});
});
const l2Test = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
});
l2Test('l2', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('l2', async({ render, page, mockEnvs }) => {
await mockEnvs(ENVS_MAP.optimisticRollup);
const component = await render(<TxInfo data={ txMock.l2tx } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
const mainnetTest = test.extend({
context: contextWithEnvs([
{ name: 'NEXT_PUBLIC_IS_TESTNET', value: 'false' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]) as any,
});
mainnetTest('without testnet warning', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('without testnet warning', async({ render, page, mockEnvs }) => {
await mockEnvs([
[ 'NEXT_PUBLIC_IS_TESTNET', 'false' ],
]);
const component = await render(<TxInfo data={ txMock.l2tx } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
......@@ -172,18 +106,9 @@ mainnetTest('without testnet warning', async({ mount, page }) => {
});
});
const stabilityTest = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.stabilityEnvs) as any,
});
stabilityTest('stability customization', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.stabilityTx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
test('stability customization', async({ render, page, mockEnvs }) => {
await mockEnvs(ENVS_MAP.stabilityEnvs);
const component = await render(<TxInfo data={ txMock.stabilityTx } isLoading={ false }/>);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
......
......@@ -160,7 +160,8 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
</Tag>
) }
</DetailsInfoItem>
{ rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && data.op_withdrawals && data.op_withdrawals.length > 0 && (
{ rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && data.op_withdrawals && data.op_withdrawals.length > 0 &&
!config.UI.views.tx.hiddenFields?.L1_status && (
<DetailsInfoItem
title="Withdrawal status"
hint="Detailed status progress of the transaction"
......@@ -181,7 +182,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
</Flex>
</DetailsInfoItem>
) }
{ data.zkevm_status && (
{ data.zkevm_status && !config.UI.views.tx.hiddenFields?.L1_status && (
<DetailsInfoItem
title="Confirmation status"
hint="Status of the transaction confirmation path to L1"
......@@ -198,7 +199,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
<TxRevertReason { ...data.revert_reason }/>
</DetailsInfoItem>
) }
{ data.zksync && (
{ data.zksync && !config.UI.views.tx.hiddenFields?.L1_status && (
<DetailsInfoItem
title="L1 status"
hint="Status is the short interpretation of the batch lifecycle"
......@@ -229,7 +230,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
</>
) }
</DetailsInfoItem>
{ data.zkevm_batch_number && (
{ data.zkevm_batch_number && !config.UI.views.tx.hiddenFields?.batch && (
<DetailsInfoItem
title="Tx batch"
hint="Batch index for this transaction"
......@@ -241,7 +242,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
/>
</DetailsInfoItem>
) }
{ data.zksync && (
{ data.zksync && !config.UI.views.tx.hiddenFields?.batch && (
<DetailsInfoItem
title="Batch"
hint="Batch number"
......
This source diff could not be displayed because it is too large. You can view the blob instead.
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