Commit f97c3771 authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into dapps-security-score

parents a43c5176 5a63649f
......@@ -95,6 +95,8 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup node
uses: actions/setup-node@v4
......@@ -115,7 +117,7 @@ jobs:
run: yarn --frozen-lockfile --ignore-optional
- name: Run Jest
run: yarn test:jest --onlyChanged=${{ github.event_name == 'pull_request' }} --passWithNoTests
run: yarn test:jest ${{ github.event_name == 'pull_request' && '--changedSince=origin/main' || '' }} --passWithNoTests
pw_affected_tests:
name: Resolve affected Playwright tests
......
......@@ -11,6 +11,7 @@ export { default as googleAnalytics } from './googleAnalytics';
export { default as graphqlApiDocs } from './graphqlApiDocs';
export { default as growthBook } from './growthBook';
export { default as marketplace } from './marketplace';
export { default as metasuites } from './metasuites';
export { default as mixpanel } from './mixpanel';
export { default as nameService } from './nameService';
export { default as restApiDocs } from './restApiDocs';
......
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const title = 'MetaSuites extension';
const config: Feature<{ isEnabled: true }> = (() => {
if (getEnvValue('NEXT_PUBLIC_METASUITES_ENABLED') === 'true') {
return Object.freeze({
title,
isEnabled: true,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
import type { TxAdditionalFieldsId, TxFieldsId } from 'types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } from 'types/views/tx';
import type { TxAdditionalFieldsId, TxFieldsId, TxViewId } from 'types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS, TX_VIEWS_IDS } from 'types/views/tx';
import { getEnvValue, parseEnvJson } from 'configs/app/utils';
......@@ -33,9 +33,31 @@ const additionalFields = (() => {
return result;
})();
const hiddenViews = (() => {
const envValue = getEnvValue('NEXT_PUBLIC_VIEWS_TX_HIDDEN_VIEWS');
if (!envValue) {
return undefined;
}
const parsedValue = parseEnvJson<Array<TxViewId>>(envValue);
if (!Array.isArray(parsedValue)) {
return undefined;
}
const result = TX_VIEWS_IDS.reduce((result, item) => {
result[item] = parsedValue.includes(item);
return result;
}, {} as Record<TxViewId, boolean>);
return result;
})();
const config = Object.freeze({
hiddenFields,
additionalFields,
hiddenViews,
});
export default config;
......@@ -46,6 +46,12 @@ NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_AD_BANNER_PROVIDER=hype
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens.services.blockscout.com
NEXT_PUBLIC_MARKETPLACE_ENABLED=true
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json
NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/appiy5yijZpMMSKjT/shr6uMGPKjj1DK7NL
NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true
......@@ -53,6 +53,7 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true'
NEXT_PUBLIC_HAS_BEACON_CHAIN=true
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth-goerli.png?raw=true
\ No newline at end of file
......@@ -43,6 +43,6 @@ NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
# rollup
NEXT_PUBLIC_IS_OPTIMISTIC_L2_NETWORK=true
NEXT_PUBLIC_OPTIMISTIC_L2_WITHDRAWAL_URL=https://app.optimism.io/bridge/withdraw
NEXT_PUBLIC_L1_BASE_URL=https://eth-goerli.blockscout.com/
NEXT_PUBLIC_ROLLUP_TYPE='optimistic'
NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://app.optimism.io/bridge/withdraw
NEXT_PUBLIC_ROLLUP_L1_BASE_URL=https://eth-goerli.blockscout.com/
\ No newline at end of file
......@@ -55,6 +55,8 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_HAS_BEACON_CHAIN=true
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_AD_BANNER_PROVIDER=getit
#meta
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/sepolia-testnet.png
......@@ -32,8 +32,8 @@ import { ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address
import { BLOCK_FIELDS_IDS } from '../../../types/views/block';
import type { BlockFieldId } from '../../../types/views/block';
import type { NftMarketplaceItem } from '../../../types/views/nft';
import type { TxAdditionalFieldsId, TxFieldsId } from '../../../types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } from '../../../types/views/tx';
import type { TxAdditionalFieldsId, TxFieldsId, TxViewId } from '../../../types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS, TX_VIEWS_IDS } from '../../../types/views/tx';
import { replaceQuotes } from '../../../configs/app/utils';
import * as regexp from '../../../lib/regexp';
......@@ -457,6 +457,11 @@ const schema = yup
.transform(replaceQuotes)
.json()
.of(yup.string<TxAdditionalFieldsId>().oneOf(TX_ADDITIONAL_FIELDS_IDS)),
NEXT_PUBLIC_VIEWS_TX_HIDDEN_VIEWS: yup
.array()
.transform(replaceQuotes)
.json()
.of(yup.string<TxViewId>().oneOf(TX_VIEWS_IDS)),
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: yup
.array()
.transform(replaceQuotes)
......@@ -507,6 +512,7 @@ const schema = yup
NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
NEXT_PUBLIC_METASUITES_ENABLED: yup.boolean(),
NEXT_PUBLIC_SWAP_BUTTON_URL: yup.string(),
NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE: yup.string<ValidatorsChainType>().oneOf(VALIDATORS_CHAIN_TYPE),
NEXT_PUBLIC_GAS_TRACKER_ENABLED: yup.boolean(),
......
......@@ -24,6 +24,7 @@ NEXT_PUBLIC_GAS_TRACKER_ENABLED=true
NEXT_PUBLIC_GAS_TRACKER_UNITS=['gwei']
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE='<a href="#">Hello</a>'
NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
......@@ -49,6 +50,7 @@ NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=['burnt_fees','total_reward']
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'NFT Marketplace','collection_url':'https://example.com/{hash}','instance_url':'https://example.com/{hash}/{id}','logo_url':'https://example.com/logo.png'}]
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS=['fee_per_gas']
NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS=['value','fee_currency','gas_price','tx_fee','gas_fees','burnt_fees']
NEXT_PUBLIC_VIEWS_TX_HIDDEN_VIEWS=['blob_txs']
NEXT_PUBLIC_VISUALIZE_API_HOST=https://example.com
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false
NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket']
......
......@@ -30,10 +30,12 @@ frontend:
kubernetes.io/ingress.class: internal-and-public
nginx.ingress.kubernetes.io/proxy-body-size: 500m
nginx.ingress.kubernetes.io/client-max-body-size: "500M"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-buffering: "on"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-send-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "8"
cert-manager.io/cluster-issuer: "zerossl-prod"
hostname: review-l2-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }}.k8s-dev.blockscout.com
......
......@@ -30,10 +30,12 @@ frontend:
kubernetes.io/ingress.class: internal-and-public
nginx.ingress.kubernetes.io/proxy-body-size: 500m
nginx.ingress.kubernetes.io/client-max-body-size: "500M"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-buffering: "on"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-send-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "8"
cert-manager.io/cluster-issuer: "zerossl-prod"
hostname: review-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }}.k8s-dev.blockscout.com
......@@ -51,7 +53,7 @@ frontend:
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-goerli.blockscout.com
NEXT_PUBLIC_API_HOST: eth-sepolia.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.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
......@@ -81,6 +83,7 @@ frontend:
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
NEXT_PUBLIC_SWAP_BUTTON_URL: uniswap
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: true
NEXT_PUBLIC_AD_BANNER_PROVIDER: getit
envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......
......@@ -52,6 +52,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Bridged tokens](ENVS.md#bridged-tokens)
- [Safe{Core} address tags](ENVS.md#safecore-address-tags)
- [SUAVE chain](ENVS.md#suave-chain)
- [MetaSuites extension](ENVS.md#metasuites-extension)
- [Sentry error monitoring](ENVS.md#sentry-error-monitoring)
- [OpenTelemetry](ENVS.md#opentelemetry)
- [Swap button](ENVS.md#swap-button)
......@@ -218,6 +219,7 @@ Settings for meta tags and OG tags
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS | `Array<TxFieldsId>` | Array of the transaction fields ids that should be hidden. See below the list of the possible id values. | - | - | `'["value","tx_fee"]'` |
| NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS | `Array<TxAdditionalFieldsId>` | Array of the additional fields ids that should be added to the transaction details. See below the list of the possible id values. | - | - | `'["fee_per_gas"]'` |
| NEXT_PUBLIC_VIEWS_TX_HIDDEN_VIEWS | `Array<TxViewId>` | Transaction views that should be hidden. See below the list of the possible id values. | - | - | `'["blob_txs"]'` |
##### Transaction fields list
| Id | Description |
......@@ -234,6 +236,11 @@ Settings for meta tags and OG tags
| --- | --- |
| `fee_per_gas` | Amount of total fee divided by total amount of gas used by transaction |
##### Transaction view list
| Id | Description |
| --- | --- |
| `blob_txs` | List of all transactions that contain blob data |
&nbsp;
#### NFT views
......@@ -344,7 +351,7 @@ This feature is **enabled by default** with the `slise` ads provider. To switch
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_AD_BANNER_PROVIDER | `slise` \| `adbutler` \| `coinzilla` \| `hype` \| `none` | Ads provider | - | `slise` | `coinzilla` |
| NEXT_PUBLIC_AD_BANNER_PROVIDER | `slise` \| `adbutler` \| `coinzilla` \| `hype` \| `getit` \| `none` | Ads provider | - | `slise` | `coinzilla` |
| NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP | `{ id: string; width: string; height: string }` | Placement config for desktop Adbutler banner | - | - | `{'id':'123456','width':'728','height':'90'}` |
| NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE | `{ id: string; width: number; height: number }` | Placement config for mobile Adbutler banner | - | - | `{'id':'654321','width':'300','height':'100'}` |
......@@ -590,6 +597,16 @@ For blockchains that implement SUAVE architecture additional fields will be show
&nbsp;
### MetaSuites extension
Enables [MetaSuites browser extension](https://github.com/blocksecteam/metasuites) to integrate with the app views.
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_METASUITES_ENABLED | `boolean` | Set to true to enable integration | Required | - | `true` |
&nbsp;
### Validators list
The feature enables the Validators page which provides detailed information about the validators of the PoS chains.
......
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.392.45C3.644.163 3.984 0 4.34 0h8.038a.63.63 0 0 1 .474.225L17.54 5.61a.83.83 0 0 1 .196.544v12.308c0 .408-.141.799-.393 1.087-.25.289-.592.451-.947.451H4.34c-.356 0-.696-.162-.948-.45A1.661 1.661 0 0 1 3 18.461V1.538c0-.408.141-.799.392-1.087Zm.948 1.088h6.87v4.497c0 .388.315.702.702.702h4.485v11.725H4.34V1.538Zm8.274.59 2.791 3.205h-2.791V2.128Z" fill="currentColor"/>
<path d="M8.1 16.4a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5h2.237c.74 0 1.305.164 1.697.491.397.324.595.808.595 1.452 0 .457-.101.85-.303 1.177-.202.328-.505.552-.909.674l.097-.223c.59.11 1.04.345 1.349.703.309.358.463.84.463 1.446 0 .709-.229 1.267-.686 1.674-.457.404-1.086.606-1.886.606H8.1Zm.746-1.12h1.765c.423 0 .76-.097 1.012-.291.255-.199.383-.517.383-.955 0-.476-.132-.805-.395-.988-.259-.187-.592-.28-1-.28H8.846v2.514Zm0-3.634h1.32c.373 0 .666-.075.88-.223.217-.152.325-.432.325-.84 0-.412-.108-.692-.325-.84-.213-.152-.507-.229-.88-.229h-1.32v2.132Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.038 12.116a2 2 0 0 1 .825.558c.309.358.463.84.463 1.446 0 .709-.229 1.267-.686 1.674-.457.404-1.086.606-1.886.606H8.1a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5h2.237c.74 0 1.305.164 1.697.491.397.324.595.808.595 1.452 0 .457-.101.85-.303 1.177a1.477 1.477 0 0 1-.534.515c.086.024.168.05.246.08Zm.177-.145c.32.137.588.327.8.573.347.403.51.936.51 1.576 0 .756-.245 1.372-.752 1.824-.504.445-1.185.656-2.019.656H8.1a.7.7 0 0 1-.7-.7v-7a.7.7 0 0 1 .7-.7h2.237c.765 0 1.383.17 1.825.537.454.371.667.92.667 1.606 0 .487-.108.917-.333 1.282-.08.13-.174.245-.281.346Zm-.72 1.237c-.217-.156-.506-.242-.883-.242H9.046v2.114h1.566c.393 0 .682-.09.889-.25.19-.147.305-.396.305-.796 0-.444-.122-.695-.309-.824l-.002-.002Zm.128 1.78c-.251.195-.589.292-1.011.292H8.846v-2.514h1.766c.407 0 .74.093 1 .28.262.183.394.512.394.988 0 .438-.128.756-.383.954Zm-.693-5.082c-.168-.12-.415-.192-.764-.192h-1.12v1.732h1.12c.35 0 .597-.07.765-.187.14-.098.24-.298.24-.676 0-.384-.1-.581-.238-.675l-.003-.002Zm.116 1.517c-.213.148-.507.223-.88.223h-1.32V9.514h1.32c.373 0 .666.076.88.229.217.148.326.428.326.84 0 .407-.109.687-.326.84Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#a)" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.647 1h12.705A2.65 2.65 0 0 1 19 3.647v12.706A2.65 2.65 0 0 1 16.353 19H3.647A2.65 2.65 0 0 1 1 16.353V3.647A2.65 2.65 0 0 1 3.647 1Zm12.705 16.94a1.59 1.59 0 0 0 1.588-1.588l.001-1.87-2.647-2.206-3.192 2.66-5.77-5.246-4.273 4.747v1.915a1.59 1.59 0 0 0 1.588 1.588h12.705Zm-1.059-7.04 2.647 2.205.001-9.457a1.59 1.59 0 0 0-1.588-1.588H3.647A1.59 1.59 0 0 0 2.06 3.647v9.208L6.257 8.19l5.874 5.342 3.162-2.634Zm1.06-4.605a2.65 2.65 0 0 1-2.647 2.647 2.65 2.65 0 0 1-2.647-2.648 2.65 2.65 0 0 1 2.647-2.647 2.65 2.65 0 0 1 2.647 2.647Zm-1.059 0a1.59 1.59 0 1 0-1.588 1.588 1.59 1.59 0 0 0 1.588-1.588Z"/>
<path d="M19 3.647h.2-.2Zm-1.06 12.705h-.2.2Zm.001-1.87h.2v-.094l-.072-.06-.128.154Zm-2.647-2.206.128-.153-.128-.107-.128.107.128.153Zm-3.192 2.66-.134.148.129.117.133-.112-.128-.153ZM6.332 9.69l.134-.148-.149-.136-.134.15.148.134Zm-4.273 4.747-.149-.134-.051.057v.077h.2Zm15.881-1.333-.128.154.328.273v-.427h-.2ZM15.293 10.9l.128-.154-.128-.107-.128.107.128.154Zm2.648-7.252h.2-.2ZM2.06 12.855h-.2v.521l.349-.387-.15-.134ZM6.257 8.19l.134-.148-.149-.135-.134.15.149.133Zm5.874 5.342-.134.148.129.117.133-.112-.128-.153ZM16.351.8H3.648v.4h12.705V.8ZM19.2 3.647A2.85 2.85 0 0 0 16.352.8v.4A2.45 2.45 0 0 1 18.8 3.647h.4Zm0 12.706V3.647h-.4v12.706h.4ZM16.353 19.2a2.85 2.85 0 0 0 2.847-2.847h-.4a2.45 2.45 0 0 1-2.447 2.447v.4Zm-12.706 0h12.706v-.4H3.647v.4ZM.8 16.353A2.85 2.85 0 0 0 3.647 19.2v-.4A2.45 2.45 0 0 1 1.2 16.353H.8Zm0-12.706v12.706h.4V3.647H.8ZM3.647.8A2.85 2.85 0 0 0 .8 3.647h.4A2.45 2.45 0 0 1 3.647 1.2V.8ZM17.74 16.352a1.39 1.39 0 0 1-1.388 1.388v.4a1.79 1.79 0 0 0 1.788-1.788h-.4Zm.001-1.87v1.87h.4v-1.87h-.4Zm-2.575-2.052 2.647 2.205.256-.307-2.647-2.205-.256.307Zm-2.936 2.66 3.192-2.66-.256-.307-3.192 2.66.256.306ZM6.197 9.837l5.77 5.246.27-.296-5.771-5.246-.27.296Zm-3.99 4.733L6.48 9.823l-.297-.267-4.273 4.747.298.268Zm.052 1.78v-1.914h-.4v1.915h.4Zm1.388 1.39a1.39 1.39 0 0 1-1.388-1.39h-.4a1.79 1.79 0 0 0 1.788 1.79v-.4Zm12.705 0H3.647v.4h12.705v-.4Zm1.716-4.79-2.647-2.206-.256.307 2.647 2.206.256-.307Zm-.327-9.304v9.457h.4V3.647h-.4ZM16.353 2.26a1.39 1.39 0 0 1 1.388 1.388h.4a1.79 1.79 0 0 0-1.788-1.788v.4Zm-12.706 0h12.706v-.4H3.647v.4ZM2.26 3.647A1.39 1.39 0 0 1 3.647 2.26v-.4A1.79 1.79 0 0 0 1.86 3.647h.4Zm0 9.208V3.647h-.4v9.208h.4Zm3.849-4.798L1.91 12.721l.298.268 4.197-4.664-.297-.268Zm6.158 5.328L6.39 8.043l-.269.296 5.875 5.342.269-.296Zm2.899-2.64-3.162 2.634.256.307 3.162-2.634-.256-.307Zm-1.46-1.604a2.85 2.85 0 0 0 2.848-2.848h-.4a2.45 2.45 0 0 1-2.447 2.448v.4Zm-2.847-2.848a2.85 2.85 0 0 0 2.848 2.848v-.4a2.45 2.45 0 0 1-2.447-2.447h-.4Zm2.848-2.847a2.85 2.85 0 0 0-2.848 2.847h.4a2.45 2.45 0 0 1 2.448-2.447v-.4Zm2.847 2.847a2.85 2.85 0 0 0-2.847-2.847v.4a2.45 2.45 0 0 1 2.447 2.447h.4Zm-2.847-1.387a1.39 1.39 0 0 1 1.388 1.388h.4a1.79 1.79 0 0 0-1.788-1.788v.4Zm-1.389 1.388a1.39 1.39 0 0 1 1.389-1.388v-.4a1.79 1.79 0 0 0-1.789 1.788h.4Zm1.389 1.389a1.39 1.39 0 0 1-1.389-1.389h-.4a1.79 1.79 0 0 0 1.789 1.789v-.4Zm1.388-1.389a1.39 1.39 0 0 1-1.388 1.389v.4a1.79 1.79 0 0 0 1.788-1.789h-.4Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#a)" fill="currentColor" stroke="currentColor" stroke-width=".4">
<path d="M15.063 19H4.937A3.937 3.937 0 0 1 1 15.062V4.938A3.937 3.937 0 0 1 4.938 1h5.625v1.125H4.936a2.813 2.813 0 0 0-2.812 2.813v10.125a2.812 2.812 0 0 0 2.813 2.812h10.125a2.812 2.812 0 0 0 2.812-2.813V9.439H19v5.624A3.937 3.937 0 0 1 15.062 19Z"/>
<path d="M15.063 8.875a3.938 3.938 0 1 1 0-7.875 3.938 3.938 0 0 1 0 7.875Zm0-6.75a2.812 2.812 0 1 0 0 5.624 2.812 2.812 0 0 0 0-5.624Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 1a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V4a3 3 0 0 0-3-3H4Zm.2 1.2a2 2 0 0 0-2 2v11.6a2 2 0 0 0 2 2h11.6a2 2 0 0 0 2-2V4.2a2 2 0 0 0-2-2H4.2Z" fill="currentColor"/>
<rect x="4" y="5" width="12" height="1.4" rx=".7" fill="currentColor"/>
<rect x="4" y="8" width="8" height="1.4" rx=".7" fill="currentColor"/>
<rect x="4" y="10.8" width="12" height="1.4" rx=".7" fill="currentColor"/>
<rect x="4" y="13.6" width="9" height="1.4" rx=".7" fill="currentColor"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 23 20">
<path fill="url(#a)" d="M1.12 1.111v5.556h2.241V2.222h4.482V0H2.241a1.13 1.13 0 0 0-.793.325c-.21.209-.327.491-.327.786Zm20.168 5.556V1.11c0-.295-.118-.577-.328-.786A1.125 1.125 0 0 0 20.168 0h-5.603v2.222h4.482v4.445h2.24Zm-2.24 11.11h-4.483V20h5.603c.297 0 .582-.117.792-.326.21-.208.328-.49.328-.785v-5.556h-2.24v4.445ZM7.842 20v-2.222H3.361v-4.445h-2.24v5.556c0 .295.117.577.327.785.21.209.496.326.793.326h5.602ZM0 8.889h22.408v2.222H0V8.89Z"/>
<defs>
<linearGradient id="a" x1="2.161" x2="21.104" y1="1.429" y2="18.729" gradientUnits="userSpaceOnUse">
<stop stop-color="#52FF00"/>
<stop offset="1" stop-color="#00EEFD"/>
</linearGradient>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="currentColor" d="M10.56 5.27A.72.72 0 0 0 10 5a.72.72 0 0 0-.56.27l-6.3 8.585a.729.729 0 0 0-.066.75A.699.699 0 0 0 3.7 15h12.6a.699.699 0 0 0 .626-.396.728.728 0 0 0-.066-.749l-6.3-8.585Z"/>
</svg>
......@@ -31,6 +31,7 @@ import type {
AddressNFTTokensFilter,
} from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses';
import type { TxBlobs, Blob } from 'types/api/blobs';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig } from 'types/api/configs';
......@@ -84,9 +85,10 @@ import type {
Transaction,
TransactionsResponseWatchlist,
TransactionsSorting,
TransactionsResponseWithBlobs,
} from 'types/api/transaction';
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { TTxsFilters } from 'types/api/txsFilters';
import type { TTxsFilters, TTxsWithBlobsFilters } from 'types/api/txsFilters';
import type { TxStateChanges } from 'types/api/txStateChanges';
import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps';
import type { ValidatorsCountersResponse, ValidatorsFilters, ValidatorsResponse, ValidatorsSorting } from 'types/api/validators';
......@@ -264,7 +266,7 @@ export const RESOURCES = {
block_txs: {
path: '/api/v2/blocks/:height_or_hash/transactions',
pathParams: [ 'height_or_hash' as const ],
filterFields: [],
filterFields: [ 'type' as const ],
},
block_withdrawals: {
path: '/api/v2/blocks/:height_or_hash/withdrawals',
......@@ -279,6 +281,10 @@ export const RESOURCES = {
path: '/api/v2/transactions',
filterFields: [ 'filter' as const, 'type' as const, 'method' as const ],
},
txs_with_blobs: {
path: '/api/v2/transactions',
filterFields: [ 'type' as const ],
},
txs_watchlist: {
path: '/api/v2/transactions/watchlist',
filterFields: [ ],
......@@ -316,6 +322,10 @@ export const RESOURCES = {
pathParams: [ 'hash' as const ],
filterFields: [],
},
tx_blobs: {
path: '/api/v2/transactions/:hash/blobs',
pathParams: [ 'hash' as const ],
},
tx_interpretation: {
path: '/api/v2/transactions/:hash/summary',
pathParams: [ 'hash' as const ],
......@@ -652,6 +662,10 @@ export const RESOURCES = {
path: '/api/v2/proxy/account-abstraction/accounts/:hash',
pathParams: [ 'hash' as const ],
},
user_op_interpretation: {
path: '/api/v2/proxy/account-abstraction/operations/:hash/summary',
pathParams: [ 'hash' as const ],
},
// VALIDATORS
validators: {
......@@ -664,6 +678,12 @@ export const RESOURCES = {
pathParams: [ 'chainType' as const ],
},
// BLOBS
blob: {
path: '/api/v2/blobs/:hash',
pathParams: [ 'hash' as const ],
},
// CONFIGS
config_backend_version: {
path: '/api/v2/config/backend-version',
......@@ -723,8 +743,8 @@ export interface ResourceError<T = unknown> {
export type ResourceErrorAccount<T> = ResourceError<{ errors: T }>
export type PaginatedResources = 'blocks' | 'block_txs' |
'txs_validated' | 'txs_pending' | 'txs_watchlist' | 'txs_execution_node' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' |
'txs_validated' | 'txs_pending' | 'txs_with_blobs' | 'txs_watchlist' | 'txs_execution_node' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_blobs' |
'addresses' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' |
......@@ -775,6 +795,7 @@ Q extends 'block_txs' ? BlockTransactionsResponse :
Q extends 'block_withdrawals' ? BlockWithdrawalsResponse :
Q extends 'txs_validated' ? TransactionsResponseValidated :
Q extends 'txs_pending' ? TransactionsResponsePending :
Q extends 'txs_with_blobs' ? TransactionsResponseWithBlobs :
Q extends 'txs_watchlist' ? TransactionsResponseWatchlist :
Q extends 'txs_execution_node' ? TransactionsResponseValidated :
Q extends 'tx' ? Transaction :
......@@ -783,6 +804,7 @@ Q extends 'tx_logs' ? LogsResponseTx :
Q extends 'tx_token_transfers' ? TokenTransferResponse :
Q extends 'tx_raw_trace' ? RawTracesResponse :
Q extends 'tx_state_changes' ? TxStateChanges :
Q extends 'tx_blobs' ? TxBlobs :
Q extends 'tx_interpretation' ? TxInterpretationResponse :
Q extends 'addresses' ? AddressesResponse :
Q extends 'address' ? Address :
......@@ -839,13 +861,6 @@ Q extends 'zkevm_l2_txn_batches_count' ? number :
Q extends 'zkevm_l2_txn_batch' ? ZkEvmL2TxnBatch :
Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs :
Q extends 'config_backend_version' ? BackendVersionConfig :
Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse :
Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount :
never;
// !!! IMPORTANT !!!
// See comment above
......@@ -853,6 +868,7 @@ never;
/* eslint-disable @typescript-eslint/indent */
export type ResourcePayloadB<Q extends ResourceName> =
Q extends 'blob' ? Blob :
Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> :
Q extends 'marketplace_dapp' ? MarketplaceAppOverview :
Q extends 'validators' ? ValidatorsResponse :
......@@ -862,6 +878,14 @@ Q extends 'shibarium_deposits' ? ShibariumDepositsResponse :
Q extends 'shibarium_withdrawals_count' ? number :
Q extends 'shibarium_deposits_count' ? number :
Q extends 'contract_security_audits' ? SmartContractSecurityAudits :
Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse :
Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount :
Q extends 'user_op_interpretation'? TxInterpretationResponse :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -874,7 +898,9 @@ export type PaginatedResponseNextPageParams<Q extends ResourceName> = Q extends
/* eslint-disable @typescript-eslint/indent */
export type PaginationFilters<Q extends PaginatedResources> =
Q extends 'blocks' ? BlockFilters :
Q extends 'block_txs' ? TTxsWithBlobsFilters :
Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters :
Q extends 'txs_with_blobs' ? TTxsWithBlobsFilters :
Q extends 'tx_token_transfers' ? TokenTransferFilters :
Q extends 'token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
......
import filetype from 'magic-bytes.js';
import hexToBytes from 'lib/hexToBytes';
import removeNonSignificantZeroBytes from './removeNonSignificantZeroBytes';
export default function guessDataType(data: string) {
const bytes = new Uint8Array(hexToBytes(data));
const filteredBytes = removeNonSignificantZeroBytes(bytes);
return filetype(filteredBytes)[0];
}
export { default as guessDataType } from './guessDataType';
export default function removeNonSignificantZeroBytes(bytes: Uint8Array) {
return shouldRemoveBytes(bytes) ? bytes.filter((item, index) => index % 32) : bytes;
}
// check if every 0, 32, 64, etc byte is 0 in the provided array
function shouldRemoveBytes(bytes: Uint8Array) {
let result = true;
for (let index = 0; index < bytes.length; index += 32) {
const element = bytes[index];
if (element === 0) {
continue;
} else {
result = false;
break;
}
}
return result;
}
export default function bytesToBase64(bytes: Uint8Array) {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
const base64String = btoa(binary);
return base64String;
}
import bytesToBase64 from './bytesToBase64';
import hexToBytes from './hexToBytes';
export default function hexToBase64(hex: string) {
const bytes = new Uint8Array(hexToBytes(hex));
return bytesToBase64(bytes);
}
// hex can be with prefix - `0x{string}` - or without it - `{string}`
export default function hexToBytes(hex: string) {
const bytes = [];
for (let c = 0; c < hex.length; c += 2) {
const startIndex = hex.startsWith('0x') ? 2 : 0;
for (let c = startIndex; c < hex.length; c += 2) {
bytes.push(parseInt(hex.substring(c, c + 2), 16));
}
return bytes;
......
import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import getQueryParamString from 'lib/router/getQueryParamString';
export default function useNotifyOnNavigation() {
const router = useRouter();
const pathname = usePathname();
const tab = getQueryParamString(router.query.tab);
React.useEffect(() => {
if (config.features.metasuites.isEnabled) {
window.postMessage({ source: 'APP_ROUTER', type: 'PATHNAME_CHANGED' }, window.location.origin);
}
}, [ pathname ]);
React.useEffect(() => {
if (config.features.metasuites.isEnabled) {
window.postMessage({ source: 'APP_ROUTER', type: 'TAB_CHANGED' }, window.location.origin);
}
}, [ tab ]);
}
......@@ -37,6 +37,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/output-roots': 'Root page',
'/batches': 'Root page',
'/batches/[number]': 'Regular page',
'/blobs/[hash]': 'Regular page',
'/ops': 'Root page',
'/op/[hash]': 'Regular page',
'/404': 'Regular page',
......
......@@ -40,6 +40,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/output-roots': DEFAULT_TEMPLATE,
'/batches': DEFAULT_TEMPLATE,
'/batches/[number]': DEFAULT_TEMPLATE,
'/blobs/[hash]': DEFAULT_TEMPLATE,
'/ops': DEFAULT_TEMPLATE,
'/op/[hash]': DEFAULT_TEMPLATE,
'/404': DEFAULT_TEMPLATE,
......
......@@ -35,6 +35,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/output-roots': 'output roots',
'/batches': 'tx batches (L2 blocks)',
'/batches/[number]': 'L2 tx batch %number%',
'/blobs/[hash]': 'blob %hash% details',
'/ops': 'user operations',
'/op/[hash]': 'user operation %hash%',
'/404': 'error - page not found',
......
......@@ -35,6 +35,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/output-roots': 'Output roots',
'/batches': 'Tx batches (L2 blocks)',
'/batches/[number]': 'L2 tx batch details',
'/blobs/[hash]': 'Blob details',
'/ops': 'User operations',
'/op/[hash]': 'User operation details',
'/404': '404',
......
......@@ -19,7 +19,7 @@ function formatValue(value: string | number, display: string | undefined, trait:
}
case 'date': {
return {
value: dayjs(value).format('YYYY-MM-DD'),
value: dayjs(Number(value) * 1000).format('YYYY-MM-DD'),
};
}
default: {
......
......@@ -38,6 +38,17 @@ export const erc20LongSymbol: AddressTokenBalance = {
token_instance: null,
};
export const erc20BigAmount: AddressTokenBalance = {
token: {
...tokens.tokenInfoERC20LongSymbol,
exchange_rate: '4200000000',
name: 'DuckDuckGoose Stable Coin',
},
token_id: null,
value: '39000000000000000000',
token_instance: null,
};
export const erc721a: AddressTokenBalance = {
token: tokens.tokenInfoERC721a,
token_id: null,
......
import type { Blob, TxBlobs } from 'types/api/blobs';
export const base1: Blob = {
blob_data: '0x004242004242004242004242004242004242',
hash: '0x016316f61a259aa607096440fc3eeb90356e079be01975d2fb18347bd50df33c',
kzg_commitment: '0xa95caabd009e189b9f205e0328ff847ad886e4f8e719bd7219875fbb9688fb3fbe7704bb1dfa7e2993a3dea8d0cf767d',
kzg_proof: '0x89cf91c4c8be6f2a390d4262425f79dffb74c174fb15a210182184543bf7394e5a7970a774ee8e0dabc315424c22df0f',
transaction_hashes: [
{ block_consensus: true, transaction_hash: '0x970d8c45c713a50a1fa351b00ca29a8890cac474c59cc8eee4eddec91a1729f0' },
],
};
export const base2: Blob = {
blob_data: '0x89504E470D0A1A0A0000000D494844520000003C0000003C0403',
hash: '0x0197fdb17195c176b23160f335daabd4b6a231aaaadd73ec567877c66a3affd1',
kzg_commitment: '0x89b0d8ac715ee134135471994a161ef068a784f51982fcd7161aa8e3e818eb83017ccfbfc30c89b796a2743d77554e2f',
kzg_proof: '0x8255a6c6a236483814b8e68992e70f3523f546866a9fed6b8e0ecfef314c65634113b8aa02d6c5c6e91b46e140f17a07',
transaction_hashes: [
{ block_consensus: true, transaction_hash: '0x22d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193' },
],
};
export const withoutData: Blob = {
blob_data: null,
hash: '0x0197fdb17195c176b23160f335daabd4b6a231aaaadd73ec567877c66a3affd3',
kzg_commitment: null,
kzg_proof: null,
transaction_hashes: [
{ block_consensus: true, transaction_hash: '0x22d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193' },
],
};
export const txBlobs: TxBlobs = {
items: [ base1, base2, withoutData ],
next_page_params: null,
};
......@@ -135,6 +135,15 @@ export const rootstock: Block = {
minimum_gas_price: '59240000',
};
export const withBlobTxs: Block = {
...base,
blob_gas_price: '21518435987',
blob_gas_used: '393216',
burnt_blob_fees: '8461393325064192',
excess_blob_gas: '79429632',
blob_tx_count: 1,
};
export const baseListResponse: BlocksResponse = {
items: [
base,
......
......@@ -6,6 +6,7 @@ import type {
SearchResultLabel,
SearchResult,
SearchResultUserOp,
SearchResultBlob,
} from 'types/api/search';
export const token1: SearchResultToken = {
......@@ -116,6 +117,12 @@ export const userOp1: SearchResultUserOp = {
url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
};
export const blob1: SearchResultBlob = {
blob_hash: '0x0108dd3e414da9f3255f7a831afa606e8dfaea93d082dfa9b15305583cbbdbbe',
type: 'blob' as const,
timestamp: null,
};
export const baseResponse: SearchResult = {
items: [
token1,
......@@ -124,6 +131,7 @@ export const baseResponse: SearchResult = {
address1,
contract1,
tx1,
blob1,
],
next_page_params: null,
};
......@@ -2,18 +2,14 @@ import type { TokenHolders } from 'types/api/token';
import { withName, withoutName } from 'mocks/address/address';
import { tokenInfoERC1155a, tokenInfoERC20a } from './tokenInfo';
export const tokenHoldersERC20: TokenHolders = {
items: [
{
address: withName,
token: tokenInfoERC20a,
value: '107014805905725000000',
},
{
address: withoutName,
token: tokenInfoERC20a,
value: '207014805905725000000',
},
],
......@@ -27,13 +23,11 @@ export const tokenHoldersERC1155: TokenHolders = {
items: [
{
address: withName,
token: tokenInfoERC1155a,
value: '107014805905725000000',
token_id: '12345',
},
{
address: withoutName,
token: tokenInfoERC1155a,
value: '207014805905725000000',
token_id: '12345',
},
......
......@@ -341,3 +341,17 @@ export const base4 = {
...base,
hash: '0x22d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
};
export const withBlob = {
...base,
blob_gas_price: '21518435987',
blob_gas_used: '131072',
blob_versioned_hashes: [
'0x01a8c328b0370068aaaef49c107f70901cd79adcda81e3599a88855532122e09',
'0x0197fdb17195c176b23160f335daabd4b6a231aaaadd73ec567877c66a3affd1',
],
burnt_blob_fee: '2820464441688064',
max_fee_per_blob_gas: '60000000000',
tx_types: [ 'blob_transaction' as const ],
type: 3,
};
......@@ -4,3 +4,10 @@ export const base = {
name: 'tom goriunov',
nickname: 'tom2drum',
};
export const withoutEmail = {
avatar: 'https://avatars.githubusercontent.com/u/22130104',
email: null,
name: 'tom goriunov',
nickname: 'tom2drum',
};
......@@ -19,6 +19,10 @@ export function ad(): CspDev.DirectiveDescriptor {
// hype
'api.hypelab.com',
'*.ixncdn.com',
//getit
'v1.getittech.io',
'ipapi.co',
],
'frame-src': [
// coinzilla
......
......@@ -28,6 +28,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/auth/unverified-email">
| DynamicRoute<"/batches/[number]", { "number": string }>
| StaticRoute<"/batches">
| DynamicRoute<"/blobs/[hash]", { "hash": string }>
| DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }>
| StaticRoute<"/blocks">
| StaticRoute<"/contract-verification">
......
......@@ -15,6 +15,7 @@ import { ChakraProvider } from 'lib/contexts/chakra';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
import { growthBook } from 'lib/growthbook/init';
import useLoadFeatures from 'lib/growthbook/useLoadFeatures';
import useNotifyOnNavigation from 'lib/hooks/useNotifyOnNavigation';
import { SocketProvider } from 'lib/socket/context';
import theme from 'theme';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
......@@ -44,6 +45,7 @@ const ERROR_SCREEN_STYLES: ChakraProps = {
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
useLoadFeatures();
useNotifyOnNavigation();
const queryClient = useQueryClientConfig();
......
......@@ -22,11 +22,13 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
return 'video';
}
if (contentType?.startsWith('image')) {
return 'image';
}
if (contentType?.startsWith('text/html')) {
return 'html';
}
return 'image';
})();
res.status(200).json({ type: mediaType });
} catch (error) {
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
const Blob = dynamic(() => import('ui/pages/Blob'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/blobs/[hash]" query={ props }>
<Blob/>
</PageNextJs>
);
};
export default Page;
export { base as getServerSideProps } from 'nextjs/getServerSideProps';
......@@ -41,6 +41,7 @@ const WALLET_CONNECT_PROJECT_ID = 'PROJECT_ID';
const wagmiConfig = defaultWagmiConfig({
chains,
projectId: WALLET_CONNECT_PROJECT_ID,
enableEmail: true,
});
createWeb3Modal({
......
......@@ -15,9 +15,14 @@
| "arrows/up-down"
| "beta_xs"
| "beta"
| "blob"
| "blobs/image"
| "blobs/raw"
| "blobs/text"
| "block_slim"
| "block"
| "brands/safe"
| "brands/solidity_scan"
| "burger"
| "check"
| "clock-light"
......@@ -136,6 +141,7 @@
| "txn_batches"
| "unfinalized"
| "uniswap"
| "up"
| "user_op_slim"
| "user_op"
| "validator"
......
import type { Blob, TxBlob } from 'types/api/blobs';
import { TX_HASH } from './tx';
const BLOB_HASH = '0x0137cd898a9aaa92bbe94999d2a98241f5eabc829d9354160061789963f85995';
const BLOB_PROOF = '0x82683d5d6e58a76f2a607b8712cad113621d46cb86a6bcfcffb1e274a70c7308b3243c6075ee22d904fecf8d4c147c6f';
export const TX_BLOB: TxBlob = {
blob_data: '0x010203040506070809101112',
hash: BLOB_HASH,
kzg_commitment: BLOB_PROOF,
kzg_proof: BLOB_PROOF,
};
export const BLOB: Blob = {
...TX_BLOB,
transaction_hashes: [
{ block_consensus: true, transaction_hash: TX_HASH },
],
};
......@@ -38,13 +38,11 @@ export const TOKEN_COUNTERS: TokenCounters = {
export const TOKEN_HOLDER_ERC_20: TokenHolder = {
address: ADDRESS_PARAMS,
token: TOKEN_INFO_ERC_20,
value: '1021378038331138520',
};
export const TOKEN_HOLDER_ERC_1155: TokenHolder = {
address: ADDRESS_PARAMS,
token: TOKEN_INFO_ERC_1155,
token_id: '12345',
value: '1021378038331138520',
};
......
export interface TxBlob {
hash: string;
blob_data: string | null;
kzg_commitment: string | null;
kzg_proof: string | null;
}
export type TxBlobs = {
items: Array<TxBlob>;
next_page_params: null;
};
export interface Blob extends TxBlob {
transaction_hashes: Array<{
block_consensus: boolean;
transaction_hash: string;
}>;
}
......@@ -36,6 +36,12 @@ export interface Block {
bitcoin_merged_mining_merkle_proof?: string | null;
hash_for_merged_mining?: string | null;
minimum_gas_price?: string | null;
// BLOB FIELDS
blob_gas_price?: string;
blob_gas_used?: string;
burnt_blob_fees?: string;
excess_blob_gas?: string;
blob_tx_count?: number;
}
export interface BlocksResponse {
......
......@@ -55,6 +55,12 @@ export interface SearchResultTx {
url?: string; // not used by the frontend, we build the url ourselves
}
export interface SearchResultBlob {
type: 'blob';
blob_hash: string;
timestamp: null;
}
export interface SearchResultUserOp {
type: 'user_operation';
user_operation_hash: string;
......@@ -62,7 +68,8 @@ export interface SearchResultUserOp {
url?: string; // not used by the frontend, we build the url ourselves
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp;
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp |
SearchResultBlob;
export interface SearchResult {
items: Array<SearchResultItem>;
......@@ -86,5 +93,5 @@ export interface SearchResultFilters {
export interface SearchRedirectResult {
parameter: string | null;
redirect: boolean;
type: 'address' | 'block' | 'transaction' | 'user_operation' | null;
type: 'address' | 'block' | 'transaction' | 'user_operation' | 'blob' | null;
}
......@@ -39,12 +39,9 @@ export type TokenHolderBase = {
value: string;
}
export type TokenHolderERC20ERC721 = TokenHolderBase & {
token: TokenInfo<'ERC-20'> | TokenInfo<'ERC-721'>;
}
export type TokenHolderERC20ERC721 = TokenHolderBase
export type TokenHolderERC1155 = TokenHolderBase & {
token: TokenInfo<'ERC-1155'>;
token_id: string;
}
......
......@@ -79,6 +79,12 @@ export type Transaction = {
zkevm_batch_number?: number;
zkevm_status?: typeof ZKEVM_L2_TX_STATUSES[number];
zkevm_sequence_hash?: string;
// blob tx fields
blob_versioned_hashes?: Array<string>;
blob_gas_used?: string;
blob_gas_price?: string;
burnt_blob_fee?: string;
max_fee_per_blob_gas?: string;
}
export const ZKEVM_L2_TX_STATUSES = [ 'Confirmed by Sequencer', 'L1 Confirmed' ];
......@@ -104,6 +110,15 @@ export interface TransactionsResponsePending {
} | null;
}
export interface TransactionsResponseWithBlobs {
items: Array<Transaction>;
next_page_params: {
block_number: number;
index: number;
items_count: number;
} | null;
}
export interface TransactionsResponseWatchlist {
items: Array<Transaction>;
next_page_params: {
......@@ -119,7 +134,8 @@ export type TransactionType = 'rootstock_remasc' |
'contract_creation' |
'contract_call' |
'token_creation' |
'coin_transfer'
'coin_transfer' |
'blob_transaction'
export type TxsResponse = TransactionsResponseValidated | TransactionsResponsePending | BlockTransactionsResponse;
......
......@@ -4,6 +4,10 @@ export type TTxsFilters = {
method?: Array<MethodFilter>;
}
export type TypeFilter = 'token_transfer' | 'contract_creation' | 'contract_call' | 'coin_transfer' | 'token_creation';
export type TTxsWithBlobsFilters = {
type: 'blob_transaction';
}
export type TypeFilter = 'token_transfer' | 'contract_creation' | 'contract_call' | 'coin_transfer' | 'token_creation' | 'blob_transaction';
export type MethodFilter = 'approve' | 'transfer' | 'multicall' | 'mint' | 'commit';
......@@ -49,8 +49,10 @@ export type UserOp = {
user_logs_start_index: number;
user_logs_count: number;
raw: {
account_gas_limits?: string;
call_data: string;
call_gas_limit: string;
gas_fees?: string;
init_code: string;
max_fee_per_gas: string;
max_priority_fee_per_gas: string;
......
......@@ -5,7 +5,7 @@ export type ZkEvmL2TxnBatchesItem = {
verify_tx_hash: string | null;
sequence_tx_hash: string | null;
status: string;
timestamp: string;
timestamp: string | null;
tx_count: number;
}
......@@ -26,7 +26,7 @@ export type ZkEvmL2TxnBatch = {
sequence_tx_hash: string;
state_root: string;
status: typeof ZKEVM_L2_TX_BATCH_STATUSES[number];
timestamp: string;
timestamp: string | null;
transactions: Array<string>;
verify_tx_hash: string;
}
......
import type { ArrayElement } from 'types/utils';
export const SUPPORTED_AD_BANNER_PROVIDERS = [ 'slise', 'adbutler', 'coinzilla', 'hype', 'none' ] as const;
export const SUPPORTED_AD_BANNER_PROVIDERS = [ 'slise', 'adbutler', 'coinzilla', 'hype', 'getit', 'none' ] as const;
export type AdBannerProviders = ArrayElement<typeof SUPPORTED_AD_BANNER_PROVIDERS>;
export const SUPPORTED_AD_TEXT_PROVIDERS = [ 'coinzilla', 'none' ] as const;
......
......@@ -16,3 +16,9 @@ export const TX_ADDITIONAL_FIELDS_IDS = [
] as const;
export type TxAdditionalFieldsId = ArrayElement<typeof TX_ADDITIONAL_FIELDS_IDS>;
export const TX_VIEWS_IDS = [
'blob_txs',
] as const;
export type TxViewId = ArrayElement<typeof TX_VIEWS_IDS>;
......@@ -6,6 +6,7 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressCounterItem from 'ui/address/details/AddressCounterItem';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
......@@ -59,15 +60,16 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
has_validated_blocks: false,
}), [ addressHash ]);
const is404Error = addressQuery.isError && 'status' in addressQuery.error && addressQuery.error.status === 404;
const is422Error = addressQuery.isError && 'status' in addressQuery.error && addressQuery.error.status === 422;
if (addressQuery.isError && is422Error) {
throwOnResourceLoadError(addressQuery);
}
if (addressQuery.isError && !is404Error) {
return <DataFetchAlert/>;
// error handling (except 404 codes)
if (addressQuery.isError) {
if (isCustomAppError(addressQuery.error)) {
const is404Error = addressQuery.isError && 'status' in addressQuery.error && addressQuery.error.status === 404;
if (!is404Error) {
throwOnResourceLoadError(addressQuery);
}
} else {
return <DataFetchAlert/>;
}
}
const data = addressQuery.isError ? error404Data : addressQuery.data;
......
......@@ -20,6 +20,7 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
import { sortTxsFromSocket } from 'ui/txs/sortTxs';
import TxsWithAPISorting from 'ui/txs/TxsWithAPISorting';
import { SORT_OPTIONS } from 'ui/txs/useTxsSort';
......@@ -85,7 +86,7 @@ const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => {
addressTxsQuery.onFilterChange({ filter: newVal });
}, [ addressTxsQuery ]);
const handleNewSocketMessage: SocketMessage.AddressTxs['handler'] = (payload) => {
const handleNewSocketMessage: SocketMessage.AddressTxs['handler'] = React.useCallback((payload) => {
setSocketAlert('');
queryClient.setQueryData(
......@@ -123,10 +124,10 @@ const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => {
items: [
...newItems,
...prevData.items,
],
].sort(sortTxsFromSocket(sort)),
};
});
};
}, [ currentAddress, filterValue, overloadCount, queryClient, sort ]);
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please refresh the page to load new transactions.');
......
import { Box, Text, chakra } from '@chakra-ui/react';
import { Box, Text, chakra, Icon } from '@chakra-ui/react';
import React from 'react';
// This icon doesn't work properly when it is in the sprite
// Probably because of the gradient
// eslint-disable-next-line no-restricted-imports
import solidityScanIcon from 'icons/brands/solidity_scan.svg';
import useApiQuery from 'lib/api/useApiQuery';
import { SOLIDITYSCAN_REPORT } from 'stubs/contract';
import LinkExternal from 'ui/shared/LinkExternal';
......@@ -39,7 +43,11 @@ const SolidityscanReport = ({ className, hash }: Props) => {
isLoading={ isPlaceholderData }
popoverContent={ (
<>
<Box mb={ 5 }>Contract analyzed for 140+ vulnerability patterns by SolidityScan</Box>
<Box mb={ 5 } lineHeight="25px">
Contract analyzed for 140+ vulnerability patterns by
<Icon as={ solidityScanIcon } mr={ 1 } ml="6px" w="23px" h="20px" display="inline-block" verticalAlign="middle"/>
<Text fontWeight={ 600 } display="inline-block">SolidityScan</Text>
</Box>
<SolidityscanReportScore score={ score }/>
{ vulnerabilities && vulnerabilitiesCount > 0 && (
<Box mb={ 5 }>
......
......@@ -163,6 +163,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
libraries={ primaryContractQuery.data?.external_libraries ?? undefined }
language={ primaryContractQuery.data?.language ?? undefined }
mainFile={ primaryEditorData[0]?.file_path }
contractName={ primaryContractQuery.data?.name || undefined }
/>
</Box>
{ secondaryEditorData && (
......@@ -173,6 +174,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
libraries={ secondaryContractQuery.data?.external_libraries ?? undefined }
language={ secondaryContractQuery.data?.language ?? undefined }
mainFile={ secondaryEditorData?.[0]?.file_path }
contractName={ secondaryContractQuery.data?.name || undefined }
/>
</Box>
) }
......
......@@ -10,6 +10,7 @@ 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';
interface Props {
......@@ -29,15 +30,22 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type });
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
const { control, setValue, getValues } = useFormContext();
const { field, fieldState } = useController({ control, name, rules: { validate, required: isOptional ? false : 'Field is required' } });
const { field, fieldState } = useController({ control, name, rules: { validate } });
const inputBgColor = useColorModeValue('white', 'black');
const nativeCoinRowBgColor = useColorModeValue('gray.100', 'gray.700');
const hasMultiplyButton = argTypeMatchInt && Number(argTypeMatchInt.power) >= 64;
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const formattedValue = format(event.target.value);
field.onChange(formattedValue); // data send back to hook form
setValue(name, formattedValue); // UI state
}, [ field, name, setValue, format ]);
const handleClear = React.useCallback(() => {
setValue(name, '');
ref.current?.focus();
......@@ -46,9 +54,9 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const handleMultiplyButtonClick = React.useCallback((power: number) => {
const zeroes = Array(power).fill('0').join('');
const value = getValues(name);
const newValue = value ? value + zeroes : '1' + zeroes;
const newValue = format(value ? value + zeroes : '1' + zeroes);
setValue(name, newValue);
}, [ getValues, name, setValue ]);
}, [ format, getValues, name, setValue ]);
const error = fieldState.error;
......@@ -76,6 +84,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
allowNegative: !argTypeMatchInt.isUnsigned,
} : {}) }
ref={ ref }
onChange={ handleChange }
required={ !isOptional }
isInvalid={ Boolean(error) }
placeholder={ data.type }
......@@ -84,7 +93,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
paddingRight={ hasMultiplyButton ? '120px' : '40px' }
/>
<InputRightElement w="auto" right={ 1 }>
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ field.value !== undefined && field.value !== '' && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasMultiplyButton && <ContractMethodMultiplyButton onClick={ handleMultiplyButtonClick } isDisabled={ isDisabled }/> }
</InputRightElement>
</InputGroup>
......
import { Box, Button, Flex, 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';
......@@ -35,7 +36,11 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
});
const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
const args = transformFormDataToMethodArgs(formData);
// The API used for reading from contracts expects all values to be strings.
const formattedData = methodType === 'read' ?
_mapValues(formData, (value) => value !== undefined ? String(value) : undefined) :
formData;
const args = transformFormDataToMethodArgs(formattedData);
setResult(undefined);
setLoading(true);
......
......@@ -20,11 +20,11 @@ const ContractMethodFormOutputs = ({ data }: Props) => {
<p>
{ data.map(({ type, name }, index) => {
return (
<>
<React.Fragment key={ index }>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < data.length - 1 && <span>, </span> }
</>
</React.Fragment>
);
}) }
</p>
......
import React from 'react';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
interface Params {
argType: SmartContractMethodArgType;
argTypeMatchInt: MatchInt | null;
}
export default function useFormatFieldValue({ argType, argTypeMatchInt }: Params) {
return React.useCallback((value: string | undefined) => {
if (!value) {
return;
}
if (argTypeMatchInt) {
const formattedString = value.replace(/\s/g, '');
return parseInt(formattedString);
}
if (argType === 'bool') {
const formattedValue = value.toLowerCase();
switch (formattedValue) {
case 'true': {
return true;
}
case 'false':{
return false;
}
default:
return value;
}
}
return value;
}, [ argType, argTypeMatchInt ]);
}
......@@ -4,7 +4,7 @@ import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import { BYTES_REGEXP, formatBooleanValue } from './utils';
import { BYTES_REGEXP } from './utils';
interface Params {
argType: SmartContractMethodArgType;
......@@ -18,13 +18,15 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt
return argType.match(BYTES_REGEXP);
}, [ argType ]);
return React.useCallback((value: string | undefined) => {
if (!value) {
// some values are formatted before they are sent to the validator
// see ./useFormatFieldValue.tsx hook
return React.useCallback((value: string | number | boolean | undefined) => {
if (value === undefined || value === '') {
return isOptional ? true : 'Field is required';
}
if (argType === 'address') {
if (!isAddress(value)) {
if (typeof value !== 'string' || !isAddress(value)) {
return 'Invalid address format';
}
......@@ -39,13 +41,11 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt
}
if (argTypeMatchInt) {
const formattedValue = Number(value.replace(/\s/g, ''));
if (Object.is(formattedValue, NaN)) {
if (typeof value !== 'number' || Object.is(value, NaN)) {
return 'Invalid integer format';
}
if (formattedValue > argTypeMatchInt.max || formattedValue < argTypeMatchInt.min) {
if (value > argTypeMatchInt.max || value < argTypeMatchInt.min) {
const lowerBoundary = argTypeMatchInt.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(argTypeMatchInt.power) - 1 }`;
const upperBoundary = argTypeMatchInt.isUnsigned ? `2 ^ ${ argTypeMatchInt.power } - 1` : `2 ^ ${ Number(argTypeMatchInt.power) - 1 } - 1`;
return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`;
......@@ -55,9 +55,8 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt
}
if (argType === 'bool') {
const formattedValue = formatBooleanValue(value);
if (formattedValue === undefined) {
return 'Invalid boolean format. Allowed values: 0, 1, true, false';
if (typeof value !== 'boolean') {
return 'Invalid boolean format. Allowed values: true, false';
}
}
......
......@@ -2,7 +2,7 @@ import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract';
export type ContractMethodFormFields = Record<string, string | undefined>;
export type ContractMethodFormFields = Record<string, string | boolean | number | undefined>;
export const INT_REGEXP = /^(u)?int(\d+)?$/i;
......@@ -17,25 +17,6 @@ export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
return [ min, max ];
};
export const formatBooleanValue = (value: string) => {
const formattedValue = value.toLowerCase();
switch (formattedValue) {
case 'true':
case '1': {
return 'true';
}
case 'false':
case '0': {
return 'false';
}
default:
return;
}
};
export function transformFormDataToMethodArgs(formData: ContractMethodFormFields) {
const result: Array<unknown> = [];
......
......@@ -148,7 +148,7 @@ base('long values', async({ mount, page }) => {
}), { times: 1 });
await page.route(TOKENS_ERC20_API_URL, async(route) => route.fulfill({
status: 200,
body: JSON.stringify({ items: [ tokensMock.erc20LongSymbol ] }),
body: JSON.stringify({ items: [ tokensMock.erc20LongSymbol, tokensMock.erc20BigAmount ] }),
}), { times: 1 });
await page.route(TOKENS_ERC721_API_URL, async(route) => route.fulfill({
status: 200,
......
......@@ -3,6 +3,7 @@ import React from 'react';
import type { FormattedData } from './types';
import { space } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
......@@ -42,7 +43,16 @@ const TokenSelectButton = ({ isOpen, isLoading, onClick, data }: Props, ref: Rea
>
<IconSvg name="tokens" boxSize={ 4 } mr={ 2 }/>
<Text fontWeight={ 600 }>{ prefix }{ num }</Text>
<Text whiteSpace="pre" variant="secondary" fontWeight={ 400 }> ({ prefix }${ usd.toFormat(2) })</Text>
<Text
whiteSpace="pre"
variant="secondary"
fontWeight={ 400 }
maxW={{ base: 'calc(100vw - 230px)', lg: '500px' }}
overflow="hidden"
textOverflow="ellipsis"
>
{ space }({ prefix }${ usd.toFormat(2) })
</Text>
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 3 }/>
</Button>
{ isLoading && !isOpen && <Skeleton h="100%" w="100%" position="absolute" top={ 0 } left={ 0 } bgColor={ skeletonBgColor } borderRadius="base"/> }
......
import { useColorModeValue, Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure } from '@chakra-ui/react';
import { Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { FormattedData } from './types';
......@@ -15,8 +15,6 @@ interface Props {
const TokenSelectDesktop = ({ data, isLoading }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const bgColor = useColorModeValue('white', 'gray.900');
const result = useTokenSelect(data);
return (
......@@ -25,7 +23,7 @@ const TokenSelectDesktop = ({ data, isLoading }: Props) => {
<TokenSelectButton isOpen={ isOpen } onClick={ onToggle } data={ result.data } isLoading={ isLoading }/>
</PopoverTrigger>
<PopoverContent w="355px" maxH="450px" overflowY="scroll">
<PopoverBody px={ 4 } py={ 6 } bgColor={ bgColor } boxShadow="2xl" >
<PopoverBody px={ 4 } py={ 6 } boxShadow="2xl" >
<TokenSelectMenu { ...result }/>
</PopoverBody>
</PopoverContent>
......
import { chakra, Flex, Text, useColorModeValue } from '@chakra-ui/react';
import { chakra, Flex, useColorModeValue } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
......@@ -62,7 +62,7 @@ const TokenSelectItem = ({ data }: Props) => {
_hover={{
bgColor: useColorModeValue('blue.50', 'gray.800'),
}}
color="initial"
color="unset"
fontSize="sm"
href={ url }
>
......@@ -73,8 +73,11 @@ const TokenSelectItem = ({ data }: Props) => {
noCopy
noLink
fontWeight={ 700 }
mr={ 2 }
/>
{ data.usd && <Text fontWeight={ 700 } ml="auto">${ data.usd.toFormat(2) }</Text> }
{ data.usd && (
<TruncatedValue value={ `$${ data.usd.toFormat(2) }` } fontWeight={ 700 } minW="120px" ml="auto" textAlign="right"/>
) }
</Flex>
<Flex alignItems="center" justifyContent="space-between" w="100%" whiteSpace="nowrap">
{ secondRow }
......
......@@ -46,17 +46,17 @@ const ERC20TokensListItem = ({ token, value, isLoading }: Props) => {
</Skeleton>
</HStack>
) }
<HStack spacing={ 3 }>
<HStack spacing={ 3 } alignItems="baseline">
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Quantity</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary">
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" whiteSpace="pre-wrap" wordBreak="break-word">
<span>{ tokenQuantity }</span>
</Skeleton>
</HStack>
{ tokenValue !== undefined && (
<HStack spacing={ 3 }>
<HStack spacing={ 3 } alignItems="baseline">
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Value</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary">
<span>{ tokenValue }</span>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" whiteSpace="pre-wrap" wordBreak="break-word">
<span>${ tokenValue }</span>
</Skeleton>
</HStack>
) }
......
......@@ -26,7 +26,8 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia
mb="18px"
url={ tokenInstance?.animation_url || tokenInstance?.image_url || null }
animationUrl={ tokenInstance?.animation_url ?? null }
imageUrl={ tokenInstance?.image_url ?? null }
isLoading={ isLoading }
/>
</Link>
......
......@@ -9,10 +9,10 @@ const TokenBalancesItem = ({ name, value, isLoading }: {name: string; value: str
return (
<Flex p={ 5 } bgColor={ bgColor } borderRadius="16px" alignItems="center">
<IconSvg name="wallet" boxSize="30px" mr={ 3 }/>
<IconSvg name="wallet" boxSize="30px" mr={ 3 } flexShrink={ 0 }/>
<Box>
<Text variant="secondary" fontSize="xs">{ name }</Text>
<Skeleton isLoaded={ !isLoading } fontWeight="500">{ value }</Skeleton>
<Skeleton isLoaded={ !isLoading } fontWeight="500" whiteSpace="pre-wrap" wordBreak="break-word">{ value }</Skeleton>
</Box>
</Flex>
);
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import BlobData from './BlobData';
import imageBlobWithZeroesBytes from './image_with_zeroes.blob';
test.use({ viewport: { width: 500, height: 300 } });
test('text', async({ mount }) => {
// eslint-disable-next-line max-len
const data = '0xE2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A280E2A3A4E2A1B6E2A0BFE2A0BFE2A0B7E2A3B6E2A384E2A080E2A080E2A080E2A080E2A0800AE2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A3B0E2A1BFE2A081E2A080E2A080E2A280E2A380E2A180E2A099E2A3B7E2A180E2A080E2A080E2A0800AE2A080E2A080E2A080E2A180E2A080E2A080E2A080E2A080E2A080E2A2A0E2A3BFE2A081E2A080E2A080E2A080E2A098E2A0BFE2A083E2A080E2A2B8E2A3BFE2A3BFE2A3BFE2A3BF0AE2A080E2A3A0E2A1BFE2A09BE2A2B7E2A3A6E2A180E2A080E2A080E2A088E2A3BFE2A184E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A3B8E2A3BFE2A3BFE2A3BFE2A09F0AE2A2B0E2A1BFE2A081E2A080E2A080E2A099E2A2BFE2A3A6E2A3A4E2A3A4E2A3BCE2A3BFE2A384E2A080E2A080E2A080E2A080E2A080E2A2B4E2A19FE2A09BE2A08BE2A081E2A0800AE2A3BFE2A087E2A080E2A080E2A080E2A080E2A080E2A089E2A089E2A089E2A089E2A089E2A081E2A080E2A080E2A080E2A080E2A080E2A088E2A3BFE2A180E2A080E2A080E2A0800AE2A3BFE2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A2B9E2A187E2A080E2A080E2A0800AE2A3BFE2A186E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A3BCE2A187E2A080E2A080E2A0800AE2A0B8E2A3B7E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A2A0E2A1BFE2A080E2A080E2A080E2A0800AE2A080E2A0B9E2A3B7E2A3A4E2A380E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A380E2A3B0E2A1BFE2A081E2A080E2A080E2A080E2A0800AE2A080E2A080E2A080E2A089E2A099E2A09BE2A0BFE2A0B6E2A3B6E2A3B6E2A3B6E2A3B6E2A3B6E2A0B6E2A0BFE2A09FE2A09BE2A089E2A080E2A080E2A080E2A080E2A080E2A080';
const component = await mount(
<TestApp>
<BlobData hash="0x01" data={ data }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
await component.locator('select').selectOption('UTF-8');
await expect(component).toHaveScreenshot();
});
test('image', async({ mount }) => {
// eslint-disable-next-line max-len
const data = '0x89504E470D0A1A0A0000000D494844520000003C0000003C0403000000C8D2C4410000000467414D410000B18F0BFC6105000000017352474200AECE1CE900000027504C54454C69712B6CB02A6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB0F4205A540000000C74524E5300ED2F788CD91B99475C09B969CFA99D0000004F7A5458745261772070726F66696C65207479706520697074630000789CE3CA2C2849E6520003230B2E630B1323134B9314031320448034C3640323B35420CBD8D4C8C4CCC41CC407CB8048A04A2E0028950EE32A226D1F0000000970485973000084DF000084DF0195C81C33000000F24944415438CB636000018E983367CE482780D90CDA40F6991D0C4820152472A60ACCE6DA03629F4E40929E03961602B39964C09C0624691B24690E88F48461215D03160903B3D962C01C07842C2758C341A80643B0B40484C3646C6C5C78E6E016171723A8E215262EEE31670E161B1B7731304C05AB155EC08002C0D172E6F80206884DBB50651938CF4003FE0CBA4390E3C56064482F53525252C329CD562A2828283A0197340B22AAB0494332C311FCD2C747A547A58996C69998D8F12745B68DA0846C85331B2CEAE8E8681A81D91F8B348C4605D0527B02A4283FA88026CD05163EAAC0900ED21EC9800EC0C2110C002BBA9FE999B920330000000049454E44AE426082';
const component = await mount(
<TestApp>
<BlobData hash="0x01" data={ data }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
await component.locator('select').selectOption('Base64');
await expect(component).toHaveScreenshot();
});
test('image blob with zeroes bytes', async({ mount }) => {
const component = await mount(
<TestApp>
<BlobData hash="0x01" data={ imageBlobWithZeroesBytes }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Flex, GridItem, Select, Skeleton, Button } from '@chakra-ui/react';
import React from 'react';
import * as blobUtils from 'lib/blob';
import removeNonSignificantZeroBytes from 'lib/blob/removeNonSignificantZeroBytes';
import bytesToBase64 from 'lib/bytesToBase64';
import downloadBlob from 'lib/downloadBlob';
import hexToBase64 from 'lib/hexToBase64';
import hexToBytes from 'lib/hexToBytes';
import hexToUtf8 from 'lib/hexToUtf8';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import BlobDataImage from './BlobDataImage';
const FORMATS = [ 'Image', 'Raw', 'UTF-8', 'Base64' ] as const;
type Format = typeof FORMATS[number];
interface Props {
data: string;
hash: string;
isLoading?: boolean;
}
const BlobData = ({ data, isLoading, hash }: Props) => {
const [ format, setFormat ] = React.useState<Format>('Raw');
const guessedType = React.useMemo(() => {
if (isLoading) {
return;
}
return blobUtils.guessDataType(data);
}, [ data, isLoading ]);
const isImage = guessedType?.mime?.startsWith('image/');
const formats = isImage ? FORMATS : FORMATS.filter((format) => format !== 'Image');
React.useEffect(() => {
if (isImage) {
setFormat('Image');
}
}, [ isImage ]);
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setFormat(event.target.value as Format);
}, []);
const handleDownloadButtonClick = React.useCallback(() => {
const fileBlob = (() => {
switch (format) {
case 'Image': {
const bytes = new Uint8Array(hexToBytes(data));
const filteredBytes = removeNonSignificantZeroBytes(bytes);
return new Blob([ filteredBytes ], { type: guessedType?.mime });
}
case 'UTF-8': {
return new Blob([ hexToUtf8(data) ], { type: guessedType?.mime ?? 'text/plain' });
}
case 'Base64': {
return new Blob([ hexToBase64(data) ], { type: 'application/octet-stream' });
}
case 'Raw': {
return new Blob([ data ], { type: 'application/octet-stream' });
}
}
})();
const fileName = `blob_${ hash }`;
downloadBlob(fileBlob, fileName);
}, [ data, format, guessedType, hash ]);
const content = (() => {
switch (format) {
case 'Image': {
if (!guessedType?.mime?.startsWith('image/')) {
return <RawDataSnippet data="Not an image" showCopy={ false } isLoading={ isLoading }/>;
}
const bytes = new Uint8Array(hexToBytes(data));
const filteredBytes = removeNonSignificantZeroBytes(bytes);
const base64 = bytesToBase64(filteredBytes);
const imgSrc = `data:${ guessedType.mime };base64,${ base64 }`;
return <BlobDataImage src={ imgSrc }/>;
}
case 'UTF-8':
return <RawDataSnippet data={ hexToUtf8(data) } showCopy={ false } isLoading={ isLoading } contentProps={{ wordBreak: 'break-word' }}/>;
case 'Base64':
return <RawDataSnippet data={ hexToBase64(data) } showCopy={ false } isLoading={ isLoading }/>;
case 'Raw':
return <RawDataSnippet data={ data } showCopy={ false } isLoading={ isLoading }/>;
default:
return <span/>;
}
})();
return (
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 3, lg: 2 }}>
<Flex alignItems="center" mb={ 3 }>
<Skeleton fontWeight={{ base: 700, lg: 500 }} isLoaded={ !isLoading }>
Blob data
</Skeleton>
<Skeleton ml={ 5 } isLoaded={ !isLoading }>
<Select
size="xs"
borderRadius="base"
value={ format }
onChange={ handleSelectChange }
focusBorderColor="none"
w="auto"
>
{ formats.map((format) => (
<option key={ format } value={ format }>{ format }</option>
)) }
</Select>
</Skeleton>
<Skeleton ml="auto" mr={ 3 } isLoaded={ !isLoading }>
<Button
variant="outline"
size="sm"
onClick={ handleDownloadButtonClick }
>
Download
</Button>
</Skeleton>
<CopyToClipboard text={ data } isLoading={ isLoading }/>
</Flex>
{ content }
</GridItem>
);
};
export default React.memo(BlobData);
import { Image, Center, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
interface Props {
src: string;
}
const BlobDataImage = ({ src }: Props) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
return (
<Center
bgColor={ bgColor }
p={ 4 }
minH="200px"
w="100%"
borderRadius="md"
>
<Image
src={ src }
objectFit="contain"
maxW="100%"
maxH="100%"
objectPosition="center"
alt="Blob image representation"
/>
</Center>
);
};
export default React.memo(BlobDataImage);
import { Alert, Grid, GridItem, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { Blob } from 'types/api/blobs';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import BlobData from './BlobData';
interface Props {
data: Blob;
isLoading?: boolean;
}
const BlobInfo = ({ data, isLoading }: Props) => {
return (
<Grid
columnGap={ 8 }
rowGap={ 3 }
templateColumns={{ base: 'minmax(0, 1fr)', lg: '216px minmax(728px, auto)' }}
>
{ !data.blob_data && (
<GridItem colSpan={{ base: undefined, lg: 2 }} mb={ 3 }>
<Skeleton isLoaded={ !isLoading }>
<Alert status="warning">This blob is not yet indexed</Alert>
</Skeleton>
</GridItem>
) }
{ data.kzg_proof && (
<DetailsInfoItem
title="Proof"
hint="Zero knowledge proof. Allows for quick verification of commitment"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden" whiteSpace="pre-wrap" wordBreak="break-all" lineHeight={ 6 } my="-2px">
{ data.kzg_proof }
<CopyToClipboard text={ data.kzg_proof } isLoading={ isLoading }/>
</Skeleton>
</DetailsInfoItem>
) }
{ data.kzg_commitment && (
<DetailsInfoItem
title="Commitment"
hint="Commitment to the data in the blob"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden" whiteSpace="pre-wrap" wordBreak="break-all" lineHeight={ 6 } my="-2px">
{ data.kzg_commitment }
<CopyToClipboard text={ data.kzg_commitment } isLoading={ isLoading }/>
</Skeleton>
</DetailsInfoItem>
) }
{ data.blob_data && (
<DetailsInfoItem
title="Size, bytes"
hint="Blob size in bytes"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden" whiteSpace="pre-wrap" wordBreak="break-all">
{ (data.blob_data.replace('0x', '').length / 2).toLocaleString() }
</Skeleton>
</DetailsInfoItem>
) }
{ data.blob_data && <DetailsInfoItemDivider/> }
{ data.transaction_hashes[0] && (
<DetailsInfoItem
title="Transaction hash"
hint="Hash of the transaction with this blob"
isLoading={ isLoading }
>
<TxEntity hash={ data.transaction_hashes[0].transaction_hash } isLoading={ isLoading } noIcon noCopy={ false }/>
</DetailsInfoItem>
) }
<DetailsSponsoredItem isLoading={ isLoading }/>
{ data.blob_data && (
<>
<DetailsInfoItemDivider/>
<BlobData data={ data.blob_data } hash={ data.hash } isLoading={ isLoading }/>
</>
) }
</Grid>
);
};
export default React.memo(BlobInfo);
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -51,6 +51,24 @@ test('genesis block', async({ mount, page }) => {
await expect(component).toHaveScreenshot();
});
test('with blob txs', async({ mount, page }) => {
const query = {
data: blockMock.withBlobTxs,
isPending: false,
} as BlockQuery;
const component = await mount(
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ 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,
......
......@@ -28,6 +28,7 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet';
import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization';
import BlockDetailsBlobInfo from './details/BlockDetailsBlobInfo';
import type { BlockQuery } from './useBlockQuery';
interface Props {
......@@ -114,6 +115,31 @@ const BlockDetails = ({ query }: Props) => {
return config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by';
})();
const txsNum = (() => {
const blockTxsNum = (
<LinkInternal href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'txs' } }) }>
{ data.tx_count } txn{ data.tx_count === 1 ? '' : 's' }
</LinkInternal>
);
const blockBlobTxsNum = data.blob_tx_count ? (
<>
<span> and </span>
<LinkInternal href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'blob_txs' } }) }>
{ data.blob_tx_count } blob txn{ data.blob_tx_count === 1 ? '' : 's' }
</LinkInternal>
</>
) : null;
return (
<>
{ blockTxsNum }
{ blockBlobTxsNum }
<span> in this block</span>
</>
);
})();
const blockTypeLabel = (() => {
switch (data.type) {
case 'reorg':
......@@ -172,9 +198,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>
<LinkInternal href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'txs' } }) }>
{ data.tx_count } transaction{ data.tx_count === 1 ? '' : 's' }
</LinkInternal>
{ txsNum }
</Skeleton>
</DetailsInfoItem>
{ config.features.beaconChain.isEnabled && Boolean(data.withdrawals_count) && (
......@@ -364,6 +388,8 @@ const BlockDetails = ({ query }: Props) => {
<>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
{ !isPlaceholderData && <BlockDetailsBlobInfo data={ data }/> }
{ data.bitcoin_merged_mining_header && (
<DetailsInfoItem
title="Bitcoin merged mining header"
......
import { Text, Tooltip } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { Block } from 'types/api/block';
import { WEI, WEI_IN_GWEI, ZERO } from 'lib/consts';
import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import IconSvg from 'ui/shared/IconSvg';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
data: Block;
}
const BlockDetailsBlobInfo = ({ data }: Props) => {
if (
!data.blob_gas_price ||
!data.blob_gas_used ||
!data.burnt_blob_fees ||
!data.excess_blob_gas
) {
return null;
}
const burntBlobFees = BigNumber(data.burnt_blob_fees || 0);
const blobFees = BigNumber(data.blob_gas_price || 0).multipliedBy(BigNumber(data.blob_gas_used || 0));
return (
<>
{ data.blob_gas_price && (
<DetailsInfoItem
title="Blob gas price"
// eslint-disable-next-line max-len
hint="Price per unit of gas used for for blob deployment. Blob gas is independent of normal gas. Both gas prices can affect the priority of transaction execution."
>
<Text>{ BigNumber(data.blob_gas_price).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.blob_gas_price).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text>
</DetailsInfoItem>
) }
{ data.blob_gas_used && (
<DetailsInfoItem
title="Blob gas used"
hint="Actual amount of gas used by the blobs in this block"
>
<Text>{ BigNumber(data.blob_gas_used).toFormat() }</Text>
</DetailsInfoItem>
) }
{ !burntBlobFees.isEqualTo(ZERO) && (
<DetailsInfoItem
title="Blob burnt fees"
hint={ `Amount of ${ currencyUnits.ether } used for blobs in this block` }
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500" mr={ 2 }/>
{ burntBlobFees.dividedBy(WEI).toFixed() } { currencyUnits.ether }
{ !blobFees.isEqualTo(ZERO) && (
<Tooltip label="Blob burnt fees / Txn fees * 100%">
<div>
<Utilization ml={ 4 } value={ burntBlobFees.dividedBy(blobFees).toNumber() }/>
</div>
</Tooltip>
) }
</DetailsInfoItem>
) }
{ data.excess_blob_gas && (
<DetailsInfoItem
title="Excess blob gas"
hint="A running total of blob gas consumed in excess of the target, prior to the block."
>
<Text>{ BigNumber(data.excess_blob_gas).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.excess_blob_gas).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text>
</DetailsInfoItem>
) }
<DetailsInfoItemDivider/>
</>
);
};
export default React.memo(BlockDetailsBlobInfo);
import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import type { BlockQuery } from './useBlockQuery';
interface Params {
heightOrHash: string;
blockQuery: BlockQuery;
tab: string;
}
export default function useBlockBlobTxsQuery({ heightOrHash, blockQuery, tab }: Params) {
const apiQuery = useQueryWithPages({
resourceName: 'block_txs',
pathParams: { height_or_hash: heightOrHash },
filters: { type: 'blob_transaction' },
options: {
enabled: Boolean(tab === 'blob_txs' && !blockQuery.isPlaceholderData && blockQuery.data?.blob_tx_count),
placeholderData: generateListStub<'block_txs'>(TX, 3, { next_page_params: null }),
refetchOnMount: false,
},
});
return apiQuery;
}
......@@ -33,7 +33,7 @@ interface Params {
tab: string;
}
export default function useBlockTxQuery({ heightOrHash, blockQuery, tab }: Params): BlockTxsQuery {
export default function useBlockTxsQuery({ heightOrHash, blockQuery, tab }: Params): BlockTxsQuery {
const [ isRefetchEnabled, setRefetchEnabled ] = React.useState(false);
const apiQuery = useQueryWithPages({
......
......@@ -7,18 +7,20 @@ import type { ChainIndicatorId } from 'types/homepage';
import type { ResourceError } from 'lib/api/resources';
import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
id: ChainIndicatorId;
title: string;
value: (stats: HomeStats) => string;
valueDiff?: (stats?: HomeStats) => number | null | undefined;
icon: React.ReactNode;
isSelected: boolean;
onClick: (id: ChainIndicatorId) => void;
stats: UseQueryResult<HomeStats, ResourceError<unknown>>;
}
const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats }: Props) => {
const ChainIndicatorItem = ({ id, title, value, valueDiff, icon, isSelected, onClick, stats }: Props) => {
const isMobile = useIsMobile();
const activeBgColorDesktop = useColorModeValue('white', 'gray.900');
......@@ -53,6 +55,25 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats
return <Text variant="secondary" fontWeight={ 600 }>{ value(stats.data) }</Text>;
})();
const valueDiffContent = (() => {
if (isMobile || !valueDiff) {
return null;
}
const diff = valueDiff(stats.data);
if (diff === undefined || diff === null) {
return null;
}
const diffColor = diff >= 0 ? 'green.500' : 'red.500';
return (
<Skeleton isLoaded={ !stats.isPlaceholderData } ml={ 3 } display="flex" alignItems="center" color={ diffColor }>
<IconSvg name="up" boxSize={ 5 } mr={ 1 } transform={ diff < 0 ? 'rotate(180deg)' : 'rotate(0)' }/>
<Text color={ diffColor } fontWeight={ 600 }>{ diff }%</Text>
</Skeleton>
);
})();
return (
<Flex
alignItems="center"
......@@ -73,7 +94,10 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats
{ icon }
<Box>
<Text fontFamily="heading" fontWeight={ 500 }>{ title }</Text>
{ valueContent }
<Flex alignItems="center">
{ valueContent }
{ valueDiffContent }
</Flex>
</Box>
</Flex>
);
......
import { Flex, Skeleton, Text, useColorModeValue } from '@chakra-ui/react';
import { Box, Flex, Skeleton, Text, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { HOMEPAGE_STATS } from 'stubs/stats';
import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg';
import ChainIndicatorChartContainer from './ChainIndicatorChartContainer';
import ChainIndicatorItem from './ChainIndicatorItem';
......@@ -56,19 +57,39 @@ const ChainIndicators = () => {
}
return (
<Text fontWeight={ 600 } fontFamily="heading" fontSize="48px" lineHeight="48px" mt={ 3 } mb={ 4 }>
<Text fontWeight={ 600 } fontFamily="heading" fontSize="48px" lineHeight="48px" mt={ 3 }>
{ indicator?.value(statsQueryResult.data) }
</Text>
);
})();
const valueDiff = (() => {
if (!statsQueryResult.data || !indicator?.valueDiff) {
return null;
}
const diff = indicator.valueDiff(statsQueryResult.data);
if (diff === undefined || diff === null) {
return null;
}
const diffColor = diff >= 0 ? 'green.500' : 'red.500';
return (
<Skeleton isLoaded={ !statsQueryResult.isPlaceholderData } display="flex" alignItems="center" color={ diffColor } mt={ 2 }>
<IconSvg name="up" boxSize={ 5 } mr={ 1 } transform={ diff < 0 ? 'rotate(180deg)' : 'rotate(0)' }/>
<Text color={ diffColor } fontWeight={ 600 }>{ diff }%</Text>
</Skeleton>
);
})();
return (
<Flex
p={{ base: 0, lg: 8 }}
borderRadius={{ base: 'none', lg: 'lg' }}
boxShadow={{ base: 'none', lg: 'xl' }}
bgColor={{ base: bgColorMobile, lg: bgColorDesktop }}
columnGap={ 12 }
columnGap={ 6 }
rowGap={ 0 }
flexDir={{ base: 'column', lg: 'row' }}
w="100%"
......@@ -80,7 +101,10 @@ const ChainIndicators = () => {
<Text fontWeight={ 500 } fontFamily="heading" fontSize="lg">{ indicator?.title }</Text>
{ indicator?.hint && <Hint label={ indicator.hint } ml={ 1 }/> }
</Flex>
{ valueTitle }
<Box mb={ 4 }>
{ valueTitle }
{ valueDiff }
</Box>
<ChainIndicatorChartContainer { ...queryResult }/>
</Flex>
{ indicators.length > 1 && (
......
......@@ -10,6 +10,7 @@ export interface TChainIndicator<R extends ChartsResources> {
id: ChainIndicatorId;
title: string;
value: (stats: HomeStats) => string;
valueDiff?: (stats?: HomeStats) => number | null | undefined;
icon: React.ReactNode;
hint?: string;
api: {
......
......@@ -54,6 +54,7 @@ const coinPriceIndicator: TChainIndicator<'stats_charts_market'> = {
value: (stats) => stats.coin_price === null ?
'$N/A' :
'$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }),
valueDiff: (stats) => stats?.coin_price !== null ? stats?.coin_price_change_percentage : null,
icon: <TokenEntity.Icon token={ nativeTokenData } boxSize={ 6 } marginRight={ 0 }/>,
hint: `${ config.chain.governanceToken.symbol || config.chain.currency.symbol } token daily price in USD.`,
api: {
......
......@@ -19,7 +19,7 @@ const MarketplaceAppAlert = ({ internalWallet, isWalletConnected }: Props) => {
icon = 'integration/full';
text = 'Your wallet is connected with Blockscout';
status = 'success';
} else if (isWalletConnected) {
} else if (!internalWallet) {
icon = 'integration/partial';
text = 'Connect your wallet in the app below';
}
......
......@@ -52,7 +52,7 @@ function sortApps(apps: Array<MarketplaceAppWithSecurityReport>, favoriteApps: A
export default function useMarketplaceApps(
filter: string,
selectedCategoryId: string = MarketplaceCategory.ALL,
favoriteApps: Array<string> = [],
favoriteApps: Array<string> | undefined = undefined,
isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types
) {
const fetch = useFetch();
......@@ -61,13 +61,16 @@ 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
const lastFavoriteAppsRef = React.useRef(favoriteApps);
const [ snapshotFavoriteApps, setSnapshotFavoriteApps ] = React.useState<Array<string> | undefined>();
React.useEffect(() => {
lastFavoriteAppsRef.current = favoriteApps;
if (isFavoriteAppsLoaded) {
setSnapshotFavoriteApps(favoriteApps);
}
}, [ selectedCategoryId, isFavoriteAppsLoaded ]); // eslint-disable-line react-hooks/exhaustive-deps
const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppWithSecurityReport>>({
queryKey: [ 'marketplace-dapps' ],
queryKey: [ 'marketplace-dapps', snapshotFavoriteApps, favoriteApps ],
queryFn: async() => {
if (!feature.isEnabled) {
return [];
......@@ -77,10 +80,10 @@ export default function useMarketplaceApps(
return apiFetch('marketplace_dapps', { pathParams: { chainId: config.chain.id } });
}
},
select: (data) => sortApps(data as Array<MarketplaceAppWithSecurityReport>, lastFavoriteAppsRef.current),
select: (data) => sortApps(data as Array<MarketplaceAppWithSecurityReport>, snapshotFavoriteApps || []),
placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined,
staleTime: Infinity,
enabled: feature.isEnabled,
enabled: feature.isEnabled && (!favoriteApps || Boolean(snapshotFavoriteApps)),
});
const appsWithSecurityReports = React.useMemo(() =>
......@@ -88,7 +91,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(() => ({
......
......@@ -236,6 +236,7 @@ const AddressPageContent = () => {
secondRow={ titleSecondRow }
isLoading={ isLoading }
/>
{ config.features.metasuites.isEnabled && <Box display="none" id="meta-suites__address" data-ready={ !isLoading }/> }
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
{ /* should stay before tabs to scroll up with pagination */ }
<Box ref={ tabsScrollRef }></Box>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as textAdMock from 'mocks/ad/textAd';
import * as blobsMock from 'mocks/blobs/blobs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import Blob from './Blob';
const BLOB_API_URL = buildApiUrl('blob', { hash: blobsMock.base1.hash });
const hooksConfig = {
router: {
query: { hash: blobsMock.base1.hash },
},
};
test.beforeEach(async({ page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
await page.route(textAdMock.duck.ad.thumbnail, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
});
});
});
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(BLOB_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blobsMock.base1),
}));
const component = await mount(
<TestApp>
<Blob/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
test('without data', async({ mount, page }) => {
await page.route(BLOB_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blobsMock.withoutData),
}));
const component = await mount(
<TestApp>
<Blob/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { BLOB } from 'stubs/blobs';
import BlobInfo from 'ui/blob/BlobInfo';
import TextAd from 'ui/shared/ad/TextAd';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
const BlobPageContent = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const { data, isPlaceholderData, isError, error } = useApiQuery('blob', {
pathParams: { hash },
queryOptions: {
placeholderData: BLOB,
refetchOnMount: false,
},
});
const content = (() => {
if (isError) {
if (isCustomAppError(error)) {
throwOnResourceLoadError({ resource: 'blob', error, isError: true });
}
return <DataFetchAlert/>;
}
if (!data) {
return null;
}
return <BlobInfo data={ data } isLoading={ isPlaceholderData }/>;
})();
const titleSecondRow = (
<BlobEntity hash={ hash } noLink fontWeight={ 500 } fontFamily="heading"/>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title="Blob details"
secondRow={ titleSecondRow }
/>
{ content }
</>
);
};
export default BlobPageContent;
......@@ -13,8 +13,9 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import BlockDetails from 'ui/block/BlockDetails';
import BlockWithdrawals from 'ui/block/BlockWithdrawals';
import useBlockBlobTxsQuery from 'ui/block/useBlockBlobTxsQuery';
import useBlockQuery from 'ui/block/useBlockQuery';
import useBlockTxQuery from 'ui/block/useBlockTxQuery';
import useBlockTxsQuery from 'ui/block/useBlockTxsQuery';
import useBlockWithdrawalsQuery from 'ui/block/useBlockWithdrawalsQuery';
import TextAd from 'ui/shared/ad/TextAd';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
......@@ -40,8 +41,9 @@ const BlockPageContent = () => {
const tab = getQueryParamString(router.query.tab);
const blockQuery = useBlockQuery({ heightOrHash });
const blockTxsQuery = useBlockTxQuery({ heightOrHash, blockQuery, tab });
const blockTxsQuery = useBlockTxsQuery({ heightOrHash, blockQuery, tab });
const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab });
const blockBlobTxsQuery = useBlockBlobTxsQuery({ heightOrHash, blockQuery, tab });
const tabs: Array<RoutedTab> = React.useMemo(() => ([
{
......@@ -64,6 +66,14 @@ const BlockPageContent = () => {
</>
),
},
blockQuery.data?.blob_tx_count ?
{
id: 'blob_txs',
title: 'Blob txns',
component: (
<TxsWithFrontendSorting query={ blockBlobTxsQuery } showBlockInfo={ false } showSocketInfo={ false }/>
),
} : null,
config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.withdrawals_count) ?
{
id: 'withdrawals',
......@@ -75,7 +85,7 @@ const BlockPageContent = () => {
</>
),
} : null,
].filter(Boolean)), [ blockQuery, blockTxsQuery, blockWithdrawalsQuery ]);
].filter(Boolean)), [ blockBlobTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery ]);
const hasPagination = !isMobile && (
(tab === 'txs' && blockTxsQuery.pagination.isVisible) ||
......
......@@ -39,7 +39,7 @@ const OptimisticL2Withdrawals = () => {
<>
<Show below="lg" ssr={ false }>{ data.items.map(((item, index) => (
<OptimisticL2WithdrawalsListItem
key={ item.l2_tx_hash + (isPlaceholderData ? index : '') }
key={ String(item.msg_nonce_version) + item.msg_nonce + (isPlaceholderData ? index : '') }
item={ item }
isLoading={ isPlaceholderData }
/>
......
......@@ -158,6 +158,31 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by blob hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.blob1.blob_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.blob1.blob_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.blob1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
const testWithUserOps = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
......
......@@ -58,6 +58,11 @@ const SearchResultsPageContent = () => {
router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return;
}
break;
}
case 'blob': {
router.replace({ pathname: '/blobs/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return;
}
}
}
......
......@@ -9,11 +9,13 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
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 PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxBlobs from 'ui/tx/TxBlobs';
import TxDetails from 'ui/tx/TxDetails';
import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded';
import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped';
......@@ -55,6 +57,9 @@ const TransactionPageContent = () => {
{ id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } :
undefined,
{ id: 'internal', title: 'Internal txns', component: <TxInternals txQuery={ txQuery }/> },
txQuery.data?.blob_versioned_hashes?.length ?
{ id: 'blobs', title: 'Blobs', component: <TxBlobs txQuery={ txQuery }/> } :
undefined,
{ id: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery }/> },
{ id: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> },
......@@ -99,7 +104,7 @@ const TransactionPageContent = () => {
})();
if (isError && !showDegradedView) {
if (error?.status === 422 || error?.status === 404) {
if (isCustomAppError(error)) {
throwOnResourceLoadError({ resource: 'tx', error, isError: true });
}
}
......
......@@ -7,6 +7,7 @@ import config from 'configs/app';
import useHasAccount from 'lib/hooks/useHasAccount';
import useIsMobile from 'lib/hooks/useIsMobile';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -26,11 +27,19 @@ const Transactions = () => {
const verifiedTitle = config.chain.verificationType === 'validation' ? 'Validated' : 'Mined';
const router = useRouter();
const isMobile = useIsMobile();
const txsQuery = useQueryWithPages({
resourceName: router.query.tab === 'pending' ? 'txs_pending' : 'txs_validated',
filters: { filter: router.query.tab === 'pending' ? 'pending' : 'validated' },
const tab = getQueryParamString(router.query.tab);
React.useEffect(() => {
if (tab === 'blob_txs' && config.UI.views.tx.hiddenViews?.blob_txs) {
router.replace({ pathname: '/txs' }, undefined, { shallow: true });
}
}, [ router, tab ]);
const txsValidatedQuery = useQueryWithPages({
resourceName: 'txs_validated',
filters: { filter: 'validated' },
options: {
enabled: !router.query.tab || router.query.tab === 'validated' || router.query.tab === 'pending',
enabled: !tab || tab === 'validated',
placeholderData: generateListStub<'txs_validated'>(TX, 50, { next_page_params: {
block_number: 9005713,
index: 5,
......@@ -40,10 +49,36 @@ const Transactions = () => {
},
});
const txsPendingQuery = useQueryWithPages({
resourceName: 'txs_pending',
filters: { filter: 'pending' },
options: {
enabled: tab === 'pending',
placeholderData: generateListStub<'txs_pending'>(TX, 50, { next_page_params: {
inserted_at: '2024-02-05T07:04:47.749818Z',
hash: '0x00',
filter: 'pending',
} }),
},
});
const txsWithBlobsQuery = useQueryWithPages({
resourceName: 'txs_with_blobs',
filters: { type: 'blob_transaction' },
options: {
enabled: !config.UI.views.tx.hiddenViews?.blob_txs && tab === 'blob_txs',
placeholderData: generateListStub<'txs_with_blobs'>(TX, 50, { next_page_params: {
block_number: 10602877,
index: 8,
items_count: 50,
} }),
},
});
const txsWatchlistQuery = useQueryWithPages({
resourceName: 'txs_watchlist',
options: {
enabled: router.query.tab === 'watchlist',
enabled: tab === 'watchlist',
placeholderData: generateListStub<'txs_watchlist'>(TX, 50, { next_page_params: {
block_number: 9005713,
index: 5,
......@@ -61,15 +96,32 @@ const Transactions = () => {
id: 'validated',
title: verifiedTitle,
component:
<TxsWithFrontendSorting query={ txsQuery } showSocketInfo={ txsQuery.pagination.page === 1 } socketInfoNum={ num } socketInfoAlert={ socketAlert }/> },
<TxsWithFrontendSorting
query={ txsValidatedQuery }
showSocketInfo={ txsValidatedQuery.pagination.page === 1 }
socketInfoNum={ num }
socketInfoAlert={ socketAlert }
/> },
{
id: 'pending',
title: 'Pending',
component: (
<TxsWithFrontendSorting
query={ txsQuery }
query={ txsPendingQuery }
showBlockInfo={ false }
showSocketInfo={ txsQuery.pagination.page === 1 }
showSocketInfo={ txsPendingQuery.pagination.page === 1 }
socketInfoNum={ num }
socketInfoAlert={ socketAlert }
/>
),
},
!config.UI.views.tx.hiddenViews?.blob_txs && {
id: 'blob_txs',
title: 'Blob txns',
component: (
<TxsWithFrontendSorting
query={ txsWithBlobsQuery }
showSocketInfo={ txsWithBlobsQuery.pagination.page === 1 }
socketInfoNum={ num }
socketInfoAlert={ socketAlert }
/>
......@@ -82,7 +134,14 @@ const Transactions = () => {
} : undefined,
].filter(Boolean);
const pagination = router.query.tab === 'watchlist' ? txsWatchlistQuery.pagination : txsQuery.pagination;
const pagination = (() => {
switch (tab) {
case 'pending': return txsPendingQuery.pagination;
case 'watchlist': return txsWatchlistQuery.pagination;
case 'blob_txs': return txsWithBlobsQuery.pagination;
default: return txsValidatedQuery.pagination;
}
})();
return (
<>
......
......@@ -13,7 +13,6 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { USER_OP } from 'stubs/userOps';
import TextAd from 'ui/shared/ad/TextAd';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
......@@ -23,6 +22,7 @@ import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import useTxQuery from 'ui/tx/useTxQuery';
import UserOpDetails from 'ui/userOp/UserOpDetails';
import UserOpRaw from 'ui/userOp/UserOpRaw';
import UserOpSubHeading from 'ui/userOp/UserOpSubHeading';
const UserOp = () => {
const router = useRouter();
......@@ -90,7 +90,7 @@ const UserOp = () => {
throwOnAbsentParamError(hash);
throwOnResourceLoadError(userOpQuery);
const titleSecondRow = <UserOpEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } fontFamily="heading"/>;
const titleSecondRow = <UserOpSubHeading hash={ hash }/>;
return (
<>
......
import { test, expect } from '@playwright/experimental-ct-react';
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses';
import * as profileMock from 'mocks/user/profile';
import authFixture from 'playwright/fixtures/auth';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
......@@ -9,6 +11,14 @@ import VerifiedAddresses from './VerifiedAddresses';
const VERIFIED_ADDRESS_URL = buildApiUrl('verified_addresses', { chainId: '1' });
const TOKEN_INFO_APPLICATIONS_URL = buildApiUrl('token_info_applications', { chainId: '1', id: undefined });
const USER_INFO_URL = buildApiUrl('user_info');
const test = base.extend({
context: ({ context }, use) => {
authFixture(context);
use(context);
},
});
test.beforeEach(async({ context }) => {
await context.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
......@@ -28,6 +38,32 @@ test('base view +@mobile', async({ mount, page }) => {
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.DEFAULT),
}));
await page.route(USER_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(profileMock.base),
}));
const component = await mount(
<TestApp>
<VerifiedAddresses/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('user without email', async({ mount, page }) => {
await page.route(VERIFIED_ADDRESS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.VERIFIED_ADDRESS_RESPONSE.DEFAULT),
}));
await page.route(TOKEN_INFO_APPLICATIONS_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.DEFAULT),
}));
await page.route(USER_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(profileMock.withoutEmail),
}));
const component = await mount(
<TestApp>
<VerifiedAddresses/>
......@@ -59,6 +95,10 @@ test('address verification flow', async({ mount, page }) => {
});
});
await page.route(USER_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(profileMock.base),
}));
await mount(
<TestApp>
<VerifiedAddresses/>
......@@ -100,6 +140,10 @@ test('application update flow', async({ mount, page }) => {
body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG),
}));
await page.route(USER_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(profileMock.base),
}));
// PUT request
await page.route(TOKEN_INFO_APPLICATION_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATION.UPDATED_ITEM),
......
import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Link } from '@chakra-ui/react';
import { OrderedList, ListItem, chakra, Button, useDisclosure, Show, Hide, Skeleton, Link, Alert } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -7,6 +7,7 @@ import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, Veri
import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import { PAGE_TYPE_DICT } from 'lib/mixpanel/getPageType';
import getQueryParamString from 'lib/router/getQueryParamString';
......@@ -37,16 +38,20 @@ const VerifiedAddresses = () => {
const modalProps = useDisclosure();
const queryClient = useQueryClient();
const userInfoQuery = useFetchProfileInfo();
const addressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: config.chain.id },
queryOptions: {
placeholderData: { verifiedAddresses: Array(3).fill(VERIFIED_ADDRESS) },
enabled: Boolean(userInfoQuery.data?.email),
},
});
const applicationsQuery = useApiQuery('token_info_applications', {
pathParams: { chainId: config.chain.id, id: undefined },
queryOptions: {
placeholderData: { submissions: Array(3).fill(TOKEN_INFO_APPLICATION) },
enabled: Boolean(userInfoQuery.data?.email),
select: (data) => {
return {
...data,
......@@ -57,6 +62,7 @@ const VerifiedAddresses = () => {
});
const isLoading = addressesQuery.isPlaceholderData || applicationsQuery.isPlaceholderData;
const userWithoutEmail = userInfoQuery.data && !userInfoQuery.data.email;
const handleGoBack = React.useCallback(() => {
setSelectedAddress(undefined);
......@@ -100,13 +106,23 @@ const VerifiedAddresses = () => {
});
}, [ queryClient ]);
const addButton = (
<Skeleton mt={ 8 } isLoaded={ !isLoading } display="inline-block">
<Button size="lg" onClick={ modalProps.onOpen }>
Add address
</Button>
</Skeleton>
);
const addButton = (() => {
if (userWithoutEmail) {
return (
<Button size="lg" isDisabled mt={ 8 }>
Add address
</Button>
);
}
return (
<Skeleton mt={ 8 } isLoaded={ !isLoading } display="inline-block">
<Button size="lg" onClick={ modalProps.onOpen }>
Add address
</Button>
</Skeleton>
);
})();
const backLink = React.useMemo(() => {
if (!selectedAddress) {
......@@ -135,35 +151,53 @@ const VerifiedAddresses = () => {
);
}
const content = addressesQuery.data?.verifiedAddresses ? (
<>
<Show below="lg" key="content-mobile" ssr={ false }>
{ addressesQuery.data.verifiedAddresses.map((item, index) => (
<VerifiedAddressesListItem
key={ item.contractAddress + (isLoading ? index : '') }
item={ item }
application={ applicationsQuery.data?.submissions?.find(({ tokenAddress }) => tokenAddress.toLowerCase() === item.contractAddress.toLowerCase()) }
onAdd={ handleItemAdd }
onEdit={ handleItemEdit }
isLoading={ isLoading }
/>
)) }
</Show>
<Hide below="lg" key="content-desktop" ssr={ false }>
<VerifiedAddressesTable
data={ addressesQuery.data.verifiedAddresses }
applications={ applicationsQuery.data?.submissions }
onItemEdit={ handleItemEdit }
onItemAdd={ handleItemAdd }
isLoading={ isLoading }
/>
</Hide>
</>
) : null;
const content = (() => {
if (userWithoutEmail) {
return null;
}
if (addressesQuery.data?.verifiedAddresses) {
return (
<>
<Show below="lg" key="content-mobile" ssr={ false }>
{ addressesQuery.data.verifiedAddresses.map((item, index) => (
<VerifiedAddressesListItem
key={ item.contractAddress + (isLoading ? index : '') }
item={ item }
application={
applicationsQuery.data?.submissions
?.find(({ tokenAddress }) => tokenAddress.toLowerCase() === item.contractAddress.toLowerCase())
}
onAdd={ handleItemAdd }
onEdit={ handleItemEdit }
isLoading={ isLoading }
/>
)) }
</Show>
<Hide below="lg" key="content-desktop" ssr={ false }>
<VerifiedAddressesTable
data={ addressesQuery.data.verifiedAddresses }
applications={ applicationsQuery.data?.submissions }
onItemEdit={ handleItemEdit }
onItemAdd={ handleItemAdd }
isLoading={ isLoading }
/>
</Hide>
</>
);
}
return null;
})();
return (
<>
<PageTitle title="My verified addresses"/>
{ userWithoutEmail && (
<Alert status="warning" mb={ 6 }>
You need a valid email address to verify addresses. Please logout of MyAccount then login using your email to proceed.
</Alert>
) }
<AccountPageDescription allowCut={ false }>
<span>
Verify ownership of a smart contract address to easily update information in Blockscout.
......@@ -188,7 +222,7 @@ const VerifiedAddresses = () => {
<AdminSupportText mt={ 5 }/>
</AccountPageDescription>
<DataListDisplay
isError={ addressesQuery.isError || applicationsQuery.isError }
isError={ userInfoQuery.isError || addressesQuery.isError || applicationsQuery.isError }
items={ addressesQuery.data?.verifiedAddresses }
content={ content }
emptyText=""
......
......@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
......@@ -200,6 +201,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
</TxEntity.Container>
);
}
case 'blob': {
return (
<BlobEntity.Container>
<BlobEntity.Icon/>
<BlobEntity.Link
isLoading={ isLoading }
hash={ data.blob_hash }
onClick={ handleLinkClick }
>
<BlobEntity.Content
asProp="mark"
hash={ data.blob_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</BlobEntity.Link>
</BlobEntity.Container>
);
}
case 'user_operation': {
return (
<UserOpEntity.Container>
......
......@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
......@@ -285,6 +286,30 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
</>
);
}
case 'blob': {
return (
<Td colSpan={ 3 } fontSize="sm">
<BlobEntity.Container>
<BlobEntity.Icon/>
<BlobEntity.Link
isLoading={ isLoading }
hash={ data.blob_hash }
onClick={ handleLinkClick }
>
<BlobEntity.Content
asProp="mark"
hash={ data.blob_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</BlobEntity.Link>
</BlobEntity.Container>
</Td>
);
}
case 'user_operation': {
return (
<>
......
......@@ -5,6 +5,7 @@ import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile';
import { getRecentSearchKeywords } from 'lib/recentSearchKeywords';
import SearchBarBackdrop from 'ui/snippets/searchBar/SearchBarBackdrop';
import SearchBarInput from 'ui/snippets/searchBar/SearchBarInput';
import SearchBarRecentKeywords from 'ui/snippets/searchBar/SearchBarRecentKeywords';
......@@ -66,33 +67,39 @@ const SearchResultsInput = ({ searchTerm, handleSubmit, handleSearchTermChange }
};
}, [ calculateMenuWidth ]);
const isSuggestOpen = isOpen && recentSearchKeywords.length > 0 && searchTerm.trim().length === 0;
return (
<Popover
isOpen={ isOpen && recentSearchKeywords.length > 0 && searchTerm.trim().length === 0 }
autoFocus={ false }
onClose={ onClose }
placement="bottom-start"
offset={ isMobile ? [ 16, -12 ] : undefined }
isLazy
>
<PopoverTrigger>
<SearchBarInput
ref={ inputRef }
onChange={ handleSearchTermChange }
onSubmit={ handleSubmit }
onFocus={ handleFocus }
onBlur={ handleBlur }
onHide={ handelHide }
onClear={ handleClear }
value={ searchTerm }
/>
</PopoverTrigger>
<PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }>
<PopoverBody py={ 6 }>
<SearchBarRecentKeywords onClick={ handleSearchTermChange } onClear={ onClose }/>
</PopoverBody>
</PopoverContent>
</Popover>
<>
<Popover
isOpen={ isSuggestOpen }
autoFocus={ false }
onClose={ onClose }
placement="bottom-start"
offset={ isMobile ? [ 16, -12 ] : undefined }
isLazy
>
<PopoverTrigger>
<SearchBarInput
ref={ inputRef }
onChange={ handleSearchTermChange }
onSubmit={ handleSubmit }
onFocus={ handleFocus }
onBlur={ handleBlur }
onHide={ handelHide }
onClear={ handleClear }
value={ searchTerm }
isSuggestOpen={ isSuggestOpen }
/>
</PopoverTrigger>
<PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }>
<PopoverBody py={ 6 }>
<SearchBarRecentKeywords onClick={ handleSearchTermChange } onClear={ onClose }/>
</PopoverBody>
</PopoverContent>
</Popover>
<SearchBarBackdrop isOpen={ isSuggestOpen }/>
</>
);
};
......
import { test, expect } from '@playwright/experimental-ct-react';
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as profileMock from 'mocks/user/profile';
import authFixture from 'playwright/fixtures/auth';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AccountActionsMenu from './AccountActionsMenu';
const USER_INFO_URL = buildApiUrl('user_info');
const test = base.extend({
context: ({ context }, use) => {
authFixture(context);
use(context);
},
});
test.use({ viewport: { width: 200, height: 200 } });
test.describe('with multiple items', async() => {
......@@ -16,6 +28,12 @@ test.describe('with multiple items', async() => {
},
};
test.beforeEach(async({ page }) => {
await page.route(USER_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(profileMock.base),
}));
});
test('base view', async({ mount, page }) => {
const component = await mount(
<TestApp>
......
......@@ -5,6 +5,7 @@ import React from 'react';
import type { ItemProps } from './types';
import config from 'configs/app';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useIsAccountActionAllowed from 'lib/hooks/useIsAccountActionAllowed';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString';
......@@ -27,6 +28,8 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
const isTxPage = router.pathname === '/tx/[hash]';
const isAccountActionAllowed = useIsAccountActionAllowed();
const userInfoQuery = useFetchProfileInfo();
const handleButtonClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Address actions (more button)' });
}, []);
......@@ -35,10 +38,12 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
return null;
}
const userWithoutEmail = userInfoQuery.data && !userInfoQuery.data.email;
const items = [
{
render: (props: ItemProps) => <TokenInfoMenuItem { ...props }/>,
enabled: isTokenPage && config.features.addressVerification.isEnabled,
enabled: isTokenPage && config.features.addressVerification.isEnabled && !userWithoutEmail,
},
{
render: (props: ItemProps) => <PrivateTagMenuItem { ...props } entityType={ isTxPage ? 'tx' : 'address' }/>,
......
......@@ -37,6 +37,7 @@ const AdditionalInfoButton = ({ isOpen, onClick, className, isLoading }: Props,
onClick={ onClick }
cursor="pointer"
flexShrink={ 0 }
aria-label="Transaction info"
>
<IconSvg
name="info"
......
......@@ -36,8 +36,8 @@ test('status code 500', async({ mount }) => {
await expect(component).toHaveScreenshot();
});
test('invalid tx hash', async({ mount }) => {
const error = { message: 'Invalid tx hash', cause: { status: 422, resource: 'tx' } } as Error;
test('tx not found', async({ mount }) => {
const error = { message: 'Not found', cause: { status: 404, resource: 'tx' } } as Error;
const component = await mount(
<TestApp>
<AppError error={ error }/>
......
......@@ -11,8 +11,8 @@ import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
import AppErrorIcon from './AppErrorIcon';
import AppErrorTitle from './AppErrorTitle';
import AppErrorBlockConsensus from './custom/AppErrorBlockConsensus';
import AppErrorInvalidTxHash from './custom/AppErrorInvalidTxHash';
import AppErrorTooManyRequests from './custom/AppErrorTooManyRequests';
import AppErrorTxNotFound from './custom/AppErrorTxNotFound';
interface Props {
className?: string;
......@@ -47,11 +47,11 @@ const AppError = ({ error, className }: Props) => {
undefined;
const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error);
const isInvalidTxHash = cause && 'resource' in cause && cause.resource === 'tx' && statusCode === 422;
const isInvalidTxHash = cause && 'resource' in cause && cause.resource === 'tx' && statusCode === 404;
const isBlockConsensus = messageInPayload?.includes('Block lost consensus');
if (isInvalidTxHash) {
return <AppErrorInvalidTxHash/>;
return <AppErrorTxNotFound/>;
}
if (isBlockConsensus) {
......
/* eslint-disable max-len */
import { Box, OrderedList, ListItem, useColorModeValue, Flex } from '@chakra-ui/react';
import { Box, OrderedList, ListItem, useColorModeValue, Flex, chakra, Button } from '@chakra-ui/react';
import React from 'react';
import { route } from 'nextjs-routes';
import IconSvg from 'ui/shared/IconSvg';
import AppErrorTitle from '../AppErrorTitle';
const AppErrorInvalidTxHash = () => {
const textColor = useColorModeValue('gray.500', 'gray.400');
const AppErrorTxNotFound = () => {
const snippet = {
borderColor: useColorModeValue('blackAlpha.300', 'whiteAlpha.300'),
iconBg: useColorModeValue('blackAlpha.800', 'whiteAlpha.800'),
......@@ -36,7 +37,7 @@ const AppErrorInvalidTxHash = () => {
</Flex>
</Box>
<AppErrorTitle title="Sorry, we are unable to locate this transaction hash"/>
<OrderedList color={ textColor } mt={ 3 } spacing={ 3 }>
<OrderedList mt={ 3 } spacing={ 3 }>
<ListItem>
If you have just submitted this transaction please wait for at least 30 seconds before refreshing this page.
</ListItem>
......@@ -47,11 +48,22 @@ const AppErrorInvalidTxHash = () => {
During times when the network is busy (i.e during ICOs) it can take a while for your transaction to propagate through the network and for us to index it.
</ListItem>
<ListItem>
If it still does not show up after 1 hour, please check with your sender/exchange/wallet/transaction provider for additional information.
<span>If it still does not show up after 1 hour, please check with your </span>
<chakra.span fontWeight={ 600 }>sender/exchange/wallet/transaction provider</chakra.span>
<span> for additional information.</span>
</ListItem>
</OrderedList>
<Button
mt={ 8 }
size="lg"
variant="outline"
as="a"
href={ route({ pathname: '/' }) }
>
Back to home
</Button>
</>
);
};
export default AppErrorInvalidTxHash;
export default AppErrorTxNotFound;
import type { ResourceError } from 'lib/api/resources';
// status codes when custom error screen should be shown
const CUSTOM_STATUS_CODES = [ 404, 422, 429 ];
export default function isCustomAppError(error: ResourceError<unknown>) {
return CUSTOM_STATUS_CODES.includes(error.status);
}
......@@ -25,7 +25,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
}, [ hasCopied ]);
if (isLoading) {
return <Skeleton boxSize={ 5 } className={ className } borderRadius="sm" flexShrink={ 0 } ml={ 2 }/>;
return <Skeleton boxSize={ 5 } className={ className } borderRadius="sm" flexShrink={ 0 } ml={ 2 } display="inline-block"/>;
}
return (
......
......@@ -9,9 +9,10 @@ const SCROLL_GRADIENT_HEIGHT = 48;
type Props = {
children: React.ReactNode;
isLoading?: boolean;
type: 'tx' | 'user_op';
}
const TxDetailsActions = ({ children, isLoading }: Props) => {
const DetailsActionsWrapper = ({ children, isLoading, type }: Props) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const [ hasScroll, setHasScroll ] = React.useState(false);
......@@ -25,8 +26,8 @@ const TxDetailsActions = ({ children, isLoading }: Props) => {
return (
<DetailsInfoItem
title="Transaction action"
hint="Highlighted events of the transaction"
title={ `${ type === 'tx' ? 'Transaction' : 'User operation' } action` }
hint={ `Highlighted events of the ${ type === 'tx' ? 'transaction' : 'user operation' }` }
note={ hasScroll ? 'Scroll to see more' : undefined }
position="relative"
isLoading={ isLoading }
......@@ -47,4 +48,4 @@ const TxDetailsActions = ({ children, isLoading }: Props) => {
);
};
export default React.memo(TxDetailsActions);
export default React.memo(DetailsActionsWrapper);
import { GridItem } from '@chakra-ui/react';
import { GridItem, chakra } from '@chakra-ui/react';
import React from 'react';
const DetailsInfoItemDivider = () => {
interface Props {
className?: string;
id?: string;
}
const DetailsInfoItemDivider = ({ className, id }: Props) => {
return (
<GridItem
id={ id }
className={ className }
colSpan={{ base: undefined, lg: 2 }}
mt={{ base: 2, lg: 3 }}
mb={{ base: 0, lg: 3 }}
......@@ -13,4 +20,4 @@ const DetailsInfoItemDivider = () => {
);
};
export default DetailsInfoItemDivider;
export default chakra(DetailsInfoItemDivider);
import type { ThemingProps } from '@chakra-ui/react';
import { Flex, chakra, useDisclosure, Popover, PopoverTrigger, PopoverContent, PopoverBody } 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';
......@@ -36,8 +37,12 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
]
.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 null;
return metaSuitesPlaceholder;
}
const content = (() => {
......@@ -60,6 +65,7 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
</Tag>
))
}
{ metaSuitesPlaceholder }
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Tag isLoading={ isLoading }onClick={ onToggle }>+{ tags.length - 1 }</Tag>
......@@ -88,18 +94,23 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
);
}
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>
));
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 (
......
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Flex, chakra, useColorModeValue, Skeleton } from '@chakra-ui/react';
import React from 'react';
......@@ -12,9 +13,10 @@ interface Props {
textareaMaxHeight?: string;
showCopy?: boolean;
isLoading?: boolean;
contentProps?: ChakraProps;
}
const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textareaMaxHeight, showCopy = true, isLoading }: Props) => {
const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textareaMaxHeight, showCopy = true, isLoading, contentProps }: Props) => {
// see issue in theme/components/Textarea.ts
const bgColor = useColorModeValue('#f5f5f6', '#1a1b1b');
return (
......@@ -36,8 +38,10 @@ const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textare
borderRadius="md"
wordBreak="break-all"
whiteSpace="pre-wrap"
overflowX="hidden"
overflowY="auto"
isLoaded={ !isLoading }
{ ...contentProps }
>
{ data }
</Skeleton>
......
......@@ -2,8 +2,8 @@ import { chakra } from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system';
import React from 'react';
const TextSeparator = (props: StyleProps) => {
return <chakra.span mx={ 3 } { ...props }>|</chakra.span>;
const TextSeparator = ({ id, ...props }: StyleProps & { id?: string }) => {
return <chakra.span mx={ 3 } id={ id } { ...props }>|</chakra.span>;
};
export default React.memo(TextSeparator);
......@@ -32,6 +32,7 @@ const getConfig = () => {
const wagmiConfig = defaultWagmiConfig({
chains,
projectId: feature.walletConnect.projectId,
enableEmail: true,
});
createWeb3Modal({
......
......@@ -7,6 +7,7 @@ import * as cookies from 'lib/cookies';
import AdbutlerBanner from './AdbutlerBanner';
import CoinzillaBanner from './CoinzillaBanner';
import GetitBanner from './GetitBanner';
import HypeBanner from './HypeBanner';
import SliseBanner from './SliseBanner';
......@@ -25,6 +26,8 @@ const AdBanner = ({ className, isLoading }: { className?: string; isLoading?: bo
return <AdbutlerBanner/>;
case 'coinzilla':
return <CoinzillaBanner/>;
case 'getit':
return <GetitBanner/>;
case 'hype':
return <HypeBanner/>;
case 'slise':
......
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';
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 isMobile = Boolean(useIsMobile());
return (
<Flex className={ className } h="90px">
<GetitAdPlugin
key={ isMobile.toString() }
apiKey={ GETIT_API_KEY }
walletConnected={ address ? address : '' }
isMobile={ isMobile }
slotId="0"
/>
</Flex>
);
};
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);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import BlobDataType from './BlobDataType';
test.use({ viewport: { width: 100, height: 50 } });
test('image data', async({ mount }) => {
const component = await mount(
<TestApp>
<BlobDataType data="0x89504E470D0A1A0A0000000D494844520000003C0000003C0403"/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('raw data', async({ mount }) => {
const component = await mount(
<TestApp>
<BlobDataType data="0x010203040506"/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('text data', async({ mount }) => {
const component = await mount(
<TestApp>
<BlobDataType data="0x7b226e616d65223a22706963732f"/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Flex, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import * as blobUtils from 'lib/blob';
import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: string;
isLoading?: boolean;
}
const TYPES: Record<string, { iconName: IconName; label: string}> = {
image: { iconName: 'blobs/image', label: 'Image' },
text: { iconName: 'blobs/text', label: 'Text' },
raw: { iconName: 'blobs/raw', label: 'Raw' },
};
const BlobDataType = ({ data, isLoading }: Props) => {
const iconColor = useColorModeValue('gray.500', 'gray.400');
const guessedType = React.useMemo(() => {
if (isLoading) {
return;
}
return blobUtils.guessDataType(data);
}, [ data, isLoading ]);
const { iconName, label } = (() => {
if (guessedType?.mime?.startsWith('image/')) {
return TYPES.image;
}
if (
guessedType?.mime?.startsWith('text/') ||
[
'application/json',
'application/xml',
'application/javascript',
].includes(guessedType?.mime || '')
) {
return TYPES.text;
}
return TYPES.raw;
})();
return (
<Flex alignItems="center" columnGap={ 2 }>
<IconSvg name={ iconName } boxSize={ 5 } color={ iconColor } isLoading={ isLoading }/>
<Skeleton isLoaded={ !isLoading }>{ label }</Skeleton>
</Flex>
);
};
export default React.memo(BlobDataType);
import { chakra } from '@chakra-ui/react';
import _omit from 'lodash/omit';
import React from 'react';
import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'hash'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/blobs/[hash]', query: { hash: props.hash } });
return (
<EntityBase.Link
{ ...props }
href={ props.href ?? defaultHref }
>
{ props.children }
</EntityBase.Link>
);
});
type IconProps = Omit<EntityBase.IconBaseProps, 'name'> & {
name?: EntityBase.IconBaseProps['name'];
};
const Icon = (props: IconProps) => {
return (
<EntityBase.Icon
{ ...props }
name={ props.name ?? 'blob' }
/>
);
};
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'hash' | 'text'>;
const Content = chakra((props: ContentProps) => {
return (
<EntityBase.Content
{ ...props }
text={ props.text ?? props.hash }
/>
);
});
type CopyProps = Omit<EntityBase.CopyBaseProps, 'text'> & Pick<EntityProps, 'hash'>;
const Copy = (props: CopyProps) => {
return (
<EntityBase.Copy
{ ...props }
text={ props.hash }
noCopy={ props.noCopy }
/>
);
};
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string;
text?: string;
}
const BlobEntity = (props: EntityProps) => {
const linkProps = _omit(props, [ 'className' ]);
const partsProps = _omit(props, [ 'className', 'onClick' ]);
return (
<Container className={ props.className }>
<Icon { ...partsProps }/>
<Link { ...linkProps }>
<Content { ...partsProps }/>
</Link>
<Copy { ...partsProps }/>
</Container>
);
};
export default React.memo(chakra(BlobEntity));
export {
Container,
Link,
Icon,
Content,
Copy,
};
......@@ -57,6 +57,7 @@ const Icon = (props: IconProps) => {
src={ props.token.icon_url ?? undefined }
alt={ `${ props.token.name || 'token' } logo` }
fallback={ <TokenLogoPlaceholder { ...styles }/> }
fallbackStrategy={ props.token.icon_url ? 'onError' : 'beforeLoadOrError' }
/>
);
};
......@@ -66,7 +67,7 @@ type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps
const Content = chakra((props: ContentProps) => {
const nameString = [
!props.onlySymbol && (props.token.name ?? 'Unnamed token'),
props.onlySymbol && (props.token.symbol ?? ''),
props.onlySymbol && (props.token.symbol ?? props.token.name ?? 'Unnamed token'),
props.token.symbol && props.jointSymbol && !props.onlySymbol && `(${ props.token.symbol })`,
].filter(Boolean).join(' ');
......
......@@ -19,6 +19,7 @@ import CodeEditorSideBar, { CONTAINER_WIDTH as SIDE_BAR_WIDTH } from './CodeEdit
import CodeEditorTabs from './CodeEditorTabs';
import addExternalLibraryWarningDecoration from './utils/addExternalLibraryWarningDecoration';
import addFileImportDecorations from './utils/addFileImportDecorations';
import addMainContractCodeDecoration from './utils/addMainContractCodeDecoration';
import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile';
import * as themes from './utils/themes';
import useThemeColors from './utils/useThemeColors';
......@@ -42,9 +43,10 @@ interface Props {
libraries?: Array<SmartContractExternalLibrary>;
language?: string;
mainFile?: string;
contractName?: string;
}
const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props) => {
const CodeEditor = ({ data, remappings, libraries, language, mainFile, contractName }: Props) => {
const [ instance, setInstance ] = React.useState<Monaco | undefined>();
const [ editor, setEditor ] = React.useState<monaco.editor.IStandaloneCodeEditor | undefined>();
const [ index, setIndex ] = React.useState(0);
......@@ -82,9 +84,10 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
if (language === 'solidity') {
loadedModels.concat(newModels)
.forEach((models) => {
addFileImportDecorations(models);
libraries?.length && addExternalLibraryWarningDecoration(models, libraries);
.forEach((model) => {
contractName && mainFile === model.uri.path && addMainContractCodeDecoration(model, contractName, editor);
addFileImportDecorations(model);
libraries?.length && addExternalLibraryWarningDecoration(model, libraries);
});
}
......@@ -192,6 +195,13 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
'.monaco-editor .overflow-guard': {
'border-bottom-left-radius': borderRadius,
},
'.monaco-editor .core-guide': {
zIndex: 1,
},
// '.monaco-editor .currentFindMatch': // TODO: find a better way to style this
'.monaco-editor .findMatch': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'],
},
'.highlight': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'],
},
......@@ -206,6 +216,18 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
'.risk-warning': {
backgroundColor: themeColors['custom.riskWarning.background'],
},
'.main-contract-header': {
backgroundColor: themeColors['custom.mainContract.header'],
},
'.main-contract-body': {
backgroundColor: themeColors['custom.mainContract.body'],
},
'.main-contract-glyph': {
zIndex: 1,
background: 'url(/static/contract_star.png) no-repeat center center',
backgroundSize: '12px',
cursor: 'pointer',
},
}), [ editorWidth, themeColors, borderRadius ]);
const renderErrorScreen = React.useCallback(() => {
......
......@@ -2,6 +2,8 @@ import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import type { SmartContractExternalLibrary } from 'types/api/contract';
import sortByEndLineNumberAsc from './sortByEndLineNumberAsc';
export default function addExternalLibraryWarningDecoration(model: monaco.editor.ITextModel, libraries: Array<SmartContractExternalLibrary>) {
const options: monaco.editor.IModelDecorationOptions = {
isWholeLine: true,
......@@ -81,15 +83,3 @@ const getLibraryName = (model: monaco.editor.ITextModel) => (library: SmartContr
return libraryName;
};
const sortByEndLineNumberAsc = (a: monaco.editor.FindMatch, b: monaco.editor.FindMatch) => {
if (a.range.endLineNumber < b.range.endLineNumber) {
return -1;
}
if (a.range.endLineNumber > b.range.endLineNumber) {
return 1;
}
return 0;
};
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import sortByEndLineNumberAsc from './sortByEndLineNumberAsc';
export default function addMainContractCodeDecoration(model: monaco.editor.ITextModel, contractName: string, editor: monaco.editor.IStandaloneCodeEditor) {
const options: monaco.editor.IModelDecorationOptions = {
isWholeLine: true,
};
const contractBlockMatches = model.findMatches(`^contract\\s`, false, true, false, null, true);
if (contractBlockMatches.length < 2) {
return;
}
const [ firstLineMatch ] = model.findMatches(`(^contract ${ contractName })( is .+)?\\s?\\{`, false, true, false, null, true);
if (!firstLineMatch) {
return;
}
const firstLineDecoration: monaco.editor.IModelDeltaDecoration = {
range: {
startColumn: 1,
endColumn: 10, // doesn't really matter since isWholeLine is true
startLineNumber: firstLineMatch.range.startLineNumber,
endLineNumber: firstLineMatch.range.startLineNumber,
},
options: {
...options,
className: '.main-contract-header',
marginClassName: '.main-contract-header',
glyphMarginClassName: '.main-contract-glyph',
glyphMarginHoverMessage: [
{ value: 'Main contract' },
],
},
};
const lastLineRange: monaco.IRange = {
startLineNumber: firstLineMatch.range.startLineNumber,
startColumn: 1,
endColumn: 10,
endLineNumber: model.getLineCount(),
};
const [ lastLineMatch ] = model
.findMatches(`^\\}`, lastLineRange, true, false, null, true)
.sort(sortByEndLineNumberAsc);
const restDecoration: monaco.editor.IModelDeltaDecoration = {
range: {
startLineNumber: firstLineMatch.range.startLineNumber + 1,
endLineNumber: lastLineMatch.range.startLineNumber,
startColumn: 1,
endColumn: 10, // doesn't really matter since isWholeLine is true
},
options: {
...options,
className: '.main-contract-body',
marginClassName: '.main-contract-body',
},
};
editor.updateOptions({ glyphMargin: true });
model.deltaDecorations([], [ firstLineDecoration, restDecoration ]);
}
import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default function sortByEndLineNumberAsc(a: monaco.editor.FindMatch, b: monaco.editor.FindMatch) {
if (a.range.endLineNumber < b.range.endLineNumber) {
return -1;
}
if (a.range.endLineNumber > b.range.endLineNumber) {
return 1;
}
return 0;
}
......@@ -37,6 +37,8 @@ export const light = {
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
'custom.riskWarning.primaryBackground': '#FEEBCB', // orange.100
'custom.riskWarning.background': '#FFFAF0', // orange.50
'custom.mainContract.header': 'rgba(233, 216, 253, 1)', // purple.100
'custom.mainContract.body': 'rgba(250, 245, 255, 1)', // purple.50
} as const,
};
......@@ -79,5 +81,7 @@ export const dark = {
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
'custom.riskWarning.primaryBackground': 'rgba(246, 173, 85, 0.3)', // orange.300
'custom.riskWarning.background': 'rgba(246, 173, 85, 0.1)', // orange.300
'custom.mainContract.header': 'rgba(183, 148, 244, 0.3)', // purple.300
'custom.mainContract.body': 'rgba(214, 188, 250, 0.1)', // purple.200
} as const,
};
......@@ -10,7 +10,54 @@ test.describe('no url', () => {
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ null }/>
<NftMedia animationUrl={ null } imageUrl={ null }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with fallback', async({ mount, page }) => {
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
await page.route(IMAGE_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_long.jpg',
});
});
const component = await mount(
<TestApp>
<NftMedia animationUrl={ null } imageUrl={ IMAGE_URL }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('non-media url and fallback', async({ mount, page }) => {
const ANIMATION_URL = 'https://localhost:3000/my-animation.m3u8';
const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(ANIMATION_URL) }`;
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
await page.route(ANIMATION_MEDIA_TYPE_API_URL, (route) => {
return route.fulfill({
status: 200,
body: JSON.stringify({ type: undefined }),
});
});
await page.route(IMAGE_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_long.jpg',
});
});
const component = await mount(
<TestApp>
<NftMedia animationUrl={ ANIMATION_URL } imageUrl={ IMAGE_URL }/>
</TestApp>,
);
......@@ -35,7 +82,7 @@ test.describe('image', () => {
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL }/>
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
</TestApp>,
);
......@@ -55,7 +102,7 @@ test('image preview hover', async({ mount, page }) => {
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL } w="250px"/>
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } w="250px"/>
</TestApp>,
);
......@@ -75,7 +122,7 @@ test('image fullscreen +@dark-mode +@mobile', async({ mount, page }) => {
});
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL } withFullscreen w="250px"/>
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } withFullscreen w="250px"/>
</TestApp>,
);
......@@ -107,7 +154,7 @@ test.describe('page', () => {
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL }/>
<NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
</TestApp>,
);
......
......@@ -9,29 +9,31 @@ import NftImage from './NftImage';
import NftImageFullscreen from './NftImageFullscreen';
import NftVideo from './NftVideo';
import NftVideoFullscreen from './NftVideoFullscreen';
import useNftMediaType from './useNftMediaType';
import useNftMediaInfo from './useNftMediaInfo';
import { mediaStyleProps } from './utils';
interface Props {
url: string | null;
imageUrl: string | null;
animationUrl: string | null;
className?: string;
isLoading?: boolean;
withFullscreen?: boolean;
}
const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
const NftMedia = ({ imageUrl, animationUrl, className, isLoading, withFullscreen }: Props) => {
const [ isMediaLoading, setIsMediaLoading ] = React.useState(true);
const [ isLoadingError, setIsLoadingError ] = React.useState(false);
const { ref, inView } = useInView({ triggerOnce: true });
const type = useNftMediaType(url, !isLoading && inView);
const mediaInfo = useNftMediaInfo({ imageUrl, animationUrl, isEnabled: !isLoading && inView });
React.useEffect(() => {
if (!isLoading) {
setIsMediaLoading(Boolean(url));
if (!isLoading && !mediaInfo) {
setIsMediaLoading(false);
setIsLoadingError(true);
}
}, [ isLoading, url ]);
}, [ isLoading, mediaInfo ]);
const handleMediaLoaded = React.useCallback(() => {
setIsMediaLoading(false);
......@@ -45,11 +47,21 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const content = (() => {
if (!url || isLoadingError) {
if (isLoading) {
return null;
}
if (!mediaInfo || isLoadingError) {
const styleProps = withFullscreen ? {} : mediaStyleProps;
return <NftFallback { ...styleProps }/>;
}
const { type, url } = mediaInfo;
if (!url) {
return null;
}
const props = {
src: url,
onLoad: handleMediaLoaded,
......@@ -70,7 +82,13 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
})();
const modal = (() => {
if (!url || !withFullscreen) {
if (!mediaInfo || !withFullscreen || isLoading) {
return null;
}
const { type, url } = mediaInfo;
if (!url) {
return null;
}
......
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes';
......@@ -9,15 +10,57 @@ import useFetch from 'lib/hooks/useFetch';
import type { MediaType } from './utils';
import { getPreliminaryMediaType } from './utils';
export default function useNftMediaType(url: string | null, isEnabled: boolean) {
interface Params {
imageUrl: string | null;
animationUrl: string | null;
isEnabled: boolean;
}
interface ReturnType {
type: MediaType | undefined;
url: string | null;
}
export default function useNftMediaInfo({ imageUrl, animationUrl, isEnabled }: Params): ReturnType | null {
const primaryQuery = useNftMediaTypeQuery(animationUrl, isEnabled);
const secondaryQuery = useNftMediaTypeQuery(imageUrl, !primaryQuery.isPending && !primaryQuery.data);
return React.useMemo(() => {
if (primaryQuery.isPending) {
return {
type: undefined,
url: animationUrl,
};
}
if (primaryQuery.data) {
return primaryQuery.data;
}
if (secondaryQuery.isPending) {
return {
type: undefined,
url: imageUrl,
};
}
if (secondaryQuery.data) {
return secondaryQuery.data;
}
return null;
}, [ animationUrl, imageUrl, primaryQuery.data, primaryQuery.isPending, secondaryQuery.data, secondaryQuery.isPending ]);
}
function useNftMediaTypeQuery(url: string | null, enabled: boolean) {
const fetch = useFetch();
const { data } = useQuery<unknown, ResourceError<unknown>, MediaType>({
return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ],
queryFn: async() => {
if (!url) {
return 'image';
return null;
}
// media could be either image, gif, video or html-page
......@@ -29,21 +72,27 @@ export default function useNftMediaType(url: string | null, isEnabled: boolean)
const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) {
return preliminaryType;
return { type: preliminaryType, url };
}
try {
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } });
const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' });
const type = await (async() => {
try {
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } });
const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' });
return 'type' in response ? response.type ?? 'image' : 'image';
} catch (error) {
return 'image';
return 'type' in response ? response.type : undefined;
} catch (error) {
return;
}
})();
if (!type) {
return null;
}
return { type, url };
},
enabled: isEnabled && Boolean(url),
enabled,
staleTime: Infinity,
});
return data;
}
......@@ -148,7 +148,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}, [ queryClient, resourceName, router, scrollToTop ]);
const onFilterChange = useCallback(<R extends PaginatedResources = Resource>(newFilters: PaginationFilters<R> | undefined) => {
const newQuery = omit<typeof router.query>(router.query, 'next_page_params', 'page', resource.filterFields);
const newQuery = omit<typeof router.query>(router.query, 'next_page_params', 'page', 'filterFields' in resource ? resource.filterFields : []);
if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => {
const isValidValue = typeof value === 'boolean' || (value && value.length);
......@@ -170,7 +170,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
setPage(1);
setPageParams(INITIAL_PAGE_PARAMS);
});
}, [ router, resource.filterFields, scrollToTop ]);
}, [ router, resource, scrollToTop ]);
const onSortingChange = useCallback((newSorting: PaginationSorting<Resource> | undefined) => {
const newQuery = {
......
......@@ -3,7 +3,7 @@ import type { MarketplaceAppOverview } from 'types/client/marketplace';
import config from 'configs/app';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob';
export type Category = ApiCategory | 'app';
export type ItemsCategoriesMap =
......@@ -23,6 +23,7 @@ export const searchCategories: Array<{id: Category; title: string }> = [
{ id: 'public_tag', title: 'Public tags' },
{ id: 'transaction', title: 'Transactions' },
{ id: 'block', title: 'Blocks' },
{ id: 'blob', title: 'Blobs' },
];
if (config.features.userOps.isEnabled) {
......@@ -38,6 +39,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' },
block: { itemTitle: 'Block', itemTitleShort: 'Block' },
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
blob: { itemTitle: 'Blob', itemTitleShort: 'Blob' },
};
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
......@@ -67,5 +69,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'user_operation': {
return 'user_operation';
}
case 'blob': {
return 'blob';
}
}
}
......@@ -16,7 +16,7 @@ const ValidatorStatus = ({ state, isLoading }: Props) => {
case 'probation':
return <StatusTag type="pending" text="Probation" isLoading={ isLoading }/>;
case 'inactive':
return <StatusTag type="error" text="Failed" isLoading={ isLoading }/>;
return <StatusTag type="error" text="Inactive" isLoading={ isLoading }/>;
}
};
......
......@@ -54,7 +54,7 @@ test('search by token name +@mobile +@dark-mode', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
......@@ -75,7 +75,7 @@ test('search by contract name +@mobile +@dark-mode', async({ mount, page }) =>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
......@@ -97,7 +97,7 @@ test('search by name homepage +@dark-mode', async({ mount, page }) => {
<SearchBar isHomepage/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
......@@ -117,7 +117,7 @@ test('search by tag +@mobile +@dark-mode', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
......@@ -137,7 +137,7 @@ test('search by address hash +@mobile', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.address1.address);
await page.getByPlaceholder(/search/i).fill(searchMock.address1.address);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
......@@ -159,7 +159,7 @@ test('search by block number +@mobile', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(String(searchMock.block1.block_number));
await page.getByPlaceholder(/search/i).fill(String(searchMock.block1.block_number));
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 600 } });
......@@ -179,7 +179,7 @@ test('search by block hash +@mobile', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.block1.block_hash);
await page.getByPlaceholder(/search/i).fill(searchMock.block1.block_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
......@@ -199,7 +199,27 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash);
await page.getByPlaceholder(/search/i).fill(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by blob hash +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.blob1.blob_hash }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
searchMock.blob1,
]),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).fill(searchMock.blob1.blob_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
......@@ -228,7 +248,7 @@ testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash);
await page.getByPlaceholder(/search/i).fill(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
......@@ -251,7 +271,7 @@ test('search with view all link', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL);
......@@ -283,7 +303,7 @@ test('scroll suggest to category', async({ mount, page }) => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL);
await page.getByRole('tab', { name: 'Addresses' }).click();
......@@ -345,7 +365,7 @@ base.describe('with apps', () => {
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type('o');
await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL);
......
import { Box, Portal, Popover, PopoverTrigger, PopoverContent, PopoverBody, useDisclosure, PopoverFooter, useOutsideClick } from '@chakra-ui/react';
import {
Box,
Portal,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
PopoverFooter,
useDisclosure,
useOutsideClick,
} from '@chakra-ui/react';
import _debounce from 'lodash/debounce';
import { useRouter } from 'next/router';
import type { FormEvent } from 'react';
......@@ -12,6 +22,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import { getRecentSearchKeywords, saveToRecentKeywords } from 'lib/recentSearchKeywords';
import LinkInternal from 'ui/shared/LinkInternal';
import SearchBarBackdrop from './SearchBarBackdrop';
import SearchBarInput from './SearchBarInput';
import SearchBarRecentKeywords from './SearchBarRecentKeywords';
import SearchBarSuggest from './SearchBarSuggest/SearchBarSuggest';
......@@ -106,69 +117,73 @@ const SearchBar = ({ isHomepage }: Props) => {
}, [ calculateMenuWidth ]);
return (
<Popover
isOpen={ isOpen && (searchTerm.trim().length > 0 || recentSearchKeywords.length > 0) }
autoFocus={ false }
onClose={ onClose }
placement="bottom-start"
offset={ isMobile && !isHomepage ? [ 16, -4 ] : undefined }
isLazy
>
<PopoverTrigger>
<SearchBarInput
ref={ inputRef }
onChange={ handleSearchTermChange }
onSubmit={ handleSubmit }
onFocus={ handleFocus }
onHide={ handelHide }
onClear={ handleClear }
isHomepage={ isHomepage }
value={ searchTerm }
/>
</PopoverTrigger>
<Portal>
<PopoverContent
w={ `${ menuWidth.current }px` }
ref={ menuRef }
>
<PopoverBody
p={ 0 }
color="chakra-body-text"
<>
<Popover
isOpen={ isOpen && (searchTerm.trim().length > 0 || recentSearchKeywords.length > 0) }
autoFocus={ false }
onClose={ onClose }
placement="bottom-start"
offset={ isMobile && !isHomepage ? [ 16, -4 ] : undefined }
isLazy
>
<PopoverTrigger>
<SearchBarInput
ref={ inputRef }
onChange={ handleSearchTermChange }
onSubmit={ handleSubmit }
onFocus={ handleFocus }
onHide={ handelHide }
onClear={ handleClear }
isHomepage={ isHomepage }
value={ searchTerm }
isSuggestOpen={ isOpen }
/>
</PopoverTrigger>
<Portal>
<PopoverContent
w={ `${ menuWidth.current }px` }
ref={ menuRef }
>
<Box
maxH="50vh"
overflowY="auto"
id={ SCROLL_CONTAINER_ID }
ref={ scrollRef }
as={ Element }
px={ 4 }
<PopoverBody
p={ 0 }
color="chakra-body-text"
>
{ searchTerm.trim().length === 0 && recentSearchKeywords.length > 0 && (
<SearchBarRecentKeywords onClick={ handleSearchTermChange } onClear={ onClose }/>
) }
{ searchTerm.trim().length > 0 && (
<SearchBarSuggest
query={ query }
searchTerm={ debouncedSearchTerm }
onItemClick={ handleItemClick }
containerId={ SCROLL_CONTAINER_ID }
/>
) }
</Box>
</PopoverBody>
{ searchTerm.trim().length > 0 && query.data && query.data.length >= 50 && (
<PopoverFooter>
<LinkInternal
href={ route({ pathname: '/search-results', query: { q: searchTerm } }) }
fontSize="sm"
<Box
maxH="50vh"
overflowY="auto"
id={ SCROLL_CONTAINER_ID }
ref={ scrollRef }
as={ Element }
px={ 4 }
>
View all results
</LinkInternal>
</PopoverFooter>
) }
</PopoverContent>
</Portal>
</Popover>
{ searchTerm.trim().length === 0 && recentSearchKeywords.length > 0 && (
<SearchBarRecentKeywords onClick={ handleSearchTermChange } onClear={ onClose }/>
) }
{ searchTerm.trim().length > 0 && (
<SearchBarSuggest
query={ query }
searchTerm={ debouncedSearchTerm }
onItemClick={ handleItemClick }
containerId={ SCROLL_CONTAINER_ID }
/>
) }
</Box>
</PopoverBody>
{ searchTerm.trim().length > 0 && query.data && query.data.length >= 50 && (
<PopoverFooter>
<LinkInternal
href={ route({ pathname: '/search-results', query: { q: searchTerm } }) }
fontSize="sm"
>
View all results
</LinkInternal>
</PopoverFooter>
) }
</PopoverContent>
</Portal>
</Popover>
<SearchBarBackdrop isOpen={ isOpen }/>
</>
);
};
......
import { Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
interface Props {
isOpen: boolean;
}
const SearchBarBackdrop = ({ isOpen }: Props) => {
const backdropBgColor = useColorModeValue('blackAlpha.400', 'blackAlpha.600');
return (
<Box
position="fixed"
top={ 0 }
left={ 0 }
w="100vw"
h="100vh"
bgColor={ backdropBgColor }
zIndex="overlay"
display={{ base: 'none', lg: isOpen ? 'block' : 'none' }}
/>
);
};
export default React.memo(SearchBarBackdrop);
......@@ -16,10 +16,14 @@ interface Props {
onHide?: () => void;
onClear: () => void;
isHomepage?: boolean;
isSuggestOpen?: boolean;
value: string;
}
const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHide, onClear, value }: Props, ref: React.ForwardedRef<HTMLFormElement>) => {
const SearchBarInput = (
{ onChange, onSubmit, isHomepage, isSuggestOpen, onFocus, onBlur, onHide, onClear, value }: Props,
ref: React.ForwardedRef<HTMLFormElement>,
) => {
const innerRef = React.useRef<HTMLFormElement>(null);
React.useImperativeHandle(ref, () => innerRef.current as HTMLFormElement, []);
const [ isSticky, setIsSticky ] = React.useState(false);
......@@ -71,10 +75,10 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid
w="100%"
backgroundColor={ bgColor }
borderRadius={{ base: isHomepage ? 'base' : 'none', lg: 'base' }}
position={{ base: isHomepage ? 'static' : 'absolute', lg: 'static' }}
position={{ base: isHomepage ? 'static' : 'absolute', lg: 'relative' }}
top={{ base: isHomepage ? 0 : 55, lg: 0 }}
left="0"
zIndex={{ base: isHomepage ? 'auto' : '-1', lg: 'auto' }}
zIndex={{ base: isHomepage ? 'auto' : '-1', lg: isSuggestOpen ? 'popover' : 'auto' }}
paddingX={{ base: isHomepage ? 0 : 4, lg: 0 }}
paddingTop={{ base: isHomepage ? 0 : 1, lg: 0 }}
paddingBottom={{ base: isHomepage ? 0 : 2, lg: 0 }}
......
import { chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultBlob } from 'types/api/search';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultBlob;
searchTerm: string;
}
const SearchBarSuggestBlob = ({ data }: Props) => {
return (
<Flex alignItems="center" minW={ 0 }>
<BlobEntity.Icon/>
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
<HashStringShortenDynamic hash={ data.blob_hash } isTooltipDisabled/>
</chakra.mark>
</Flex>
);
};
export default React.memo(SearchBarSuggestBlob);
......@@ -7,6 +7,7 @@ import type { SearchResultItem } from 'types/api/search';
import { route } from 'nextjs-routes';
import SearchBarSuggestAddress from './SearchBarSuggestAddress';
import SearchBarSuggestBlob from './SearchBarSuggestBlob';
import SearchBarSuggestBlock from './SearchBarSuggestBlock';
import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel';
......@@ -42,6 +43,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'user_operation': {
return route({ pathname: '/op/[hash]', query: { hash: data.user_operation_hash } });
}
case 'blob': {
return route({ pathname: '/blobs/[hash]', query: { hash: data.blob_hash } });
}
}
})();
......@@ -67,6 +71,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'user_operation': {
return <SearchBarSuggestUserOp data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
case 'blob': {
return <SearchBarSuggestBlob data={ data } searchTerm={ searchTerm }/>;
}
}
})();
......
......@@ -68,7 +68,7 @@ const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => {
>
{ items.map((item, index) => (
<TokenInventoryItem
key={ token.address + '_' + item.id + (inventoryQuery.isPlaceholderData ? '_' + index : '') }
key={ item.id + '_' + index + (inventoryQuery.isPlaceholderData ? '_' + 'placeholder' : '') }
item={ item }
isLoading={ inventoryQuery.isPlaceholderData || tokenQuery.isPlaceholderData }
token={ token }
......
......@@ -20,7 +20,8 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
const mediaElement = (
<NftMedia
mb="18px"
url={ item.animation_url || item.image_url }
animationUrl={ item.animation_url }
imageUrl={ item.image_url }
isLoading={ isLoading }
/>
);
......
......@@ -17,10 +17,12 @@ const TriggerButton = ({ onClick }: Props, ref: React.ForwardedRef<HTMLButtonEle
onClick={ onClick }
aria-label="Show project info"
fontWeight={ 500 }
px={ 2 }
lineHeight={ 6 }
pl={ 1 }
pr={ 2 }
h="32px"
>
<IconSvg name="rocket" boxSize={ 5 } mr={ 1 }/>
<IconSvg name="info" boxSize={ 6 } mr={ 1 }/>
<span>Info</span>
</Button>
);
......
......@@ -74,7 +74,8 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
<TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/>
</Grid>
<NftMedia
url={ data.animation_url || data.image_url }
animationUrl={ data.animation_url }
imageUrl={ data.image_url }
w="250px"
flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }}
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as blobsMock from 'mocks/blobs/blobs';
import * as txMock from 'mocks/txs/tx';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TxBlobs from './TxBlobs';
import type { TxQuery } from './useTxQuery';
const TX_BLOBS_API_URL = buildApiUrl('tx_blobs', { hash: txMock.base.hash });
const hooksConfig = {
router: {
query: { hash: txMock.base.hash },
},
};
test('base view +@mobile', async({ mount, page }) => {
await page.route(TX_BLOBS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blobsMock.txBlobs),
}));
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
const component = await mount(
<TestApp>
<TxBlobs txQuery={ txQuery }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
import { Hide, Show } from '@chakra-ui/react';
import React from 'react';
import { TX_BLOB } from 'stubs/blobs';
import { generateListStub } from 'stubs/utils';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxBlobsList from './blobs/TxBlobsList';
import TxBlobsTable from './blobs/TxBlobsTable';
import TxPendingAlert from './TxPendingAlert';
import TxSocketAlert from './TxSocketAlert';
import type { TxQuery } from './useTxQuery';
interface Props {
txQuery: TxQuery;
}
const TxBlobs = ({ txQuery }: Props) => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_blobs',
pathParams: { hash: txQuery.data?.hash },
options: {
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: generateListStub<'tx_blobs'>(TX_BLOB, 3, { next_page_params: null }),
},
});
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
const content = data ? (
<>
<Hide below="lg" ssr={ false }>
<TxBlobsTable data={ data.items } isLoading={ isPlaceholderData } top={ pagination.isVisible ? 80 : 0 }/>
</Hide>
<Show below="lg" ssr={ false }>
<TxBlobsList data={ data.items } isLoading={ isPlaceholderData }/>
</Show>
</>
) : null;
const actionBar = pagination.isVisible ? (
<ActionBar mt={ -6 } showShadow>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) : null;
return (
<DataListDisplay
isError={ isError || txQuery.isError }
items={ data?.items }
emptyText="There are no blobs for this transaction."
content={ content }
actionBar={ actionBar }
/>
);
};
export default TxBlobs;
......@@ -7,12 +7,14 @@ import type { Transaction } from 'types/api/transaction';
import { SECOND } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import hexToDecimal from 'lib/hexToDecimal';
import { publicClient } from 'lib/web3/client';
import { GET_BLOCK, GET_TRANSACTION, GET_TRANSACTION_RECEIPT, GET_TRANSACTION_CONFIRMATIONS } from 'stubs/RPC';
import { unknownAddress } from 'ui/shared/address/utils';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
import TestnetWarning from 'ui/shared/alerts/TestnetWarning';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TxInfo from './details/TxInfo';
......@@ -141,8 +143,8 @@ const TxDetailsDegraded = ({ hash, txQuery }: Props) => {
}, [ txQuery.setRefetchOnError ]);
if (!query.data) {
if (originalError?.status === 404) {
throw Error('Not found', { cause: { status: 404 } as unknown as Error });
if (originalError && isCustomAppError(originalError)) {
throwOnResourceLoadError({ resource: 'tx', error: originalError, isError: true });
}
return <DataFetchAlert/>;
......
......@@ -5,10 +5,10 @@ import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import { TX_ACTIONS_BLOCK_ID } from 'ui/shared/DetailsActionsWrapper';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import { TX_ACTIONS_BLOCK_ID } from 'ui/tx/details/txDetailsActions/TxDetailsActionsWrapper';
import TxInterpretation from 'ui/tx/interpretation/TxInterpretation';
import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation';
import type { TxQuery } from './useTxQuery';
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { TxBlob } from 'types/api/blobs';
import BlobDataType from 'ui/shared/blob/BlobDataType';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
interface Props {
data: TxBlob;
isLoading?: boolean;
}
const TxBlobListItem = ({ data, isLoading }: Props) => {
const size = data.blob_data ? data.blob_data.replace('0x', '').length / 2 : '-';
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Blob hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlobEntity hash={ data.hash } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Data type</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
{ data.blob_data ? <BlobDataType isLoading={ isLoading } data={ data.blob_data }/> : '-' }
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Size, bytes</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
{ size.toLocaleString() }
</Skeleton>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default React.memo(TxBlobListItem);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TxBlob } from 'types/api/blobs';
import TxBlobListItem from './TxBlobListItem';
const TxBlobList = ({ data, isLoading }: { data: Array<TxBlob>; isLoading?: boolean }) => {
return (
<Box>
{ data.map((item, index) => (
<TxBlobListItem
key={ item.hash + (isLoading ? index : '') }
data={ item }
isLoading={ isLoading }
/>
)) }
</Box>
);
};
export default TxBlobList;
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { TxBlob } from 'types/api/blobs';
import { default as Thead } from 'ui/shared/TheadSticky';
import TxBlobsTableItem from './TxBlobsTableItem';
interface Props {
data: Array<TxBlob>;
top: number;
isLoading?: boolean;
}
const TxInternalsTable = ({ data, top, isLoading }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
<Th width="60%">Blob hash</Th>
<Th width="20%">Data type</Th>
<Th width="20%">Size, bytes</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item, index) => (
<TxBlobsTableItem key={ item.hash + (isLoading ? index : '') } data={ item } isLoading={ isLoading }/>
)) }
</Tbody>
</Table>
);
};
export default TxInternalsTable;
import { Tr, Td, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { TxBlob } from 'types/api/blobs';
import BlobDataType from 'ui/shared/blob/BlobDataType';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
interface Props {
data: TxBlob;
isLoading?: boolean;
}
const TxBlobsTableItem = ({ data, isLoading }: Props) => {
const size = data.blob_data ? data.blob_data.replace('0x', '').length / 2 : '-';
return (
<Tr alignItems="top">
<Td>
<BlobEntity hash={ data.hash } noIcon isLoading={ isLoading }/>
</Td>
<Td verticalAlign="middle">
{ data.blob_data ? <BlobDataType isLoading={ isLoading } data={ data.blob_data }/> : '-' }
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ size.toLocaleString() }
</Skeleton>
</Td>
</Tr>
);
};
export default React.memo(TxBlobsTableItem);
import BigNumber from 'bignumber.js';
import React from 'react';
import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import { ZERO } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import IconSvg from 'ui/shared/IconSvg';
const rollupFeature = config.features.rollup;
interface Props {
data: Transaction;
isLoading?: boolean;
}
const TxDetailsBurntFees = ({ data, isLoading }: Props) => {
if (config.UI.views.tx.hiddenFields?.burnt_fees || (rollupFeature.isEnabled && rollupFeature.type === 'optimistic')) {
return null;
}
const value = BigNumber(data.tx_burnt_fee || 0).plus(BigNumber(data.blob_gas_used || 0).multipliedBy(BigNumber(data.blob_gas_price || 0)));
if (value.isEqualTo(ZERO)) {
return null;
}
return (
<DetailsInfoItem
title="Burnt fees"
hint={ `
Amount of ${ currencyUnits.ether } burned for this transaction. Equals Block Base Fee per Gas * Gas Used
${ data.blob_gas_price && data.blob_gas_used ? ' + Blob Gas Price * Blob Gas Used' : '' }
` }
isLoading={ isLoading }
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500" isLoading={ isLoading }/>
<CurrencyValue
value={ value.toString() }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
ml={ 2 }
isLoading={ isLoading }
/>
</DetailsInfoItem>
);
};
export default React.memo(TxDetailsBurntFees);
......@@ -21,6 +21,7 @@ const TxDetailsOther = ({ nonce, type, position }: Props) => {
<Text as="span" fontWeight="500">Txn type: </Text>
<Text fontWeight="600" as="span">{ type }</Text>
{ type === 2 && <Text fontWeight="400" as="span" ml={ 1 } variant="secondary">(EIP-1559)</Text> }
{ type === 3 && <Text fontWeight="400" as="span" ml={ 1 } variant="secondary">(EIP-4844)</Text> }
</Box>
),
<Box key="nonce">
......
......@@ -116,6 +116,22 @@ 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 },
);
await page.getByText('View details').click();
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
const l2Test = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
......
......@@ -45,6 +45,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability';
import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
import TxDetailsBurntFees from 'ui/tx/details/TxDetailsBurntFees';
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
......@@ -108,6 +109,15 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
return (
<Grid columnGap={ 8 } rowGap={{ base: 3, lg: 3 }} templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}>
{ config.features.metasuites.isEnabled && (
<>
<Box display="none" id="meta-suites__tx-info-label" data-status={ data.status } data-ready={ !isLoading }/>
<Box display="none" id="meta-suites__tx-info-value"/>
<DetailsInfoItemDivider display="none" id="meta-suites__details-info-item-divider"/>
</>
) }
{ socketStatus && (
<GridItem colSpan={{ base: undefined, lg: 2 }} mb={ 2 }>
<TxSocketAlert status={ socketStatus }/>
......@@ -124,6 +134,13 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
<HashStringShortenDynamic hash={ data.hash }/>
</Skeleton>
<CopyToClipboard text={ data.hash } isLoading={ isLoading }/>
{ config.features.metasuites.isEnabled && (
<>
<TextSeparator color="gray.500" flexShrink={ 0 } display="none" id="meta-suites__tx-explorer-separator"/>
<Box display="none" flexShrink={ 0 } id="meta-suites__tx-explorer-link"/>
</>
) }
</DetailsInfoItem>
<DetailsInfoItem
title={ rollupFeature.isEnabled && rollupFeature.type === 'zkEvm' ? 'L2 status and method' : 'Status and method' }
......@@ -357,7 +374,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<DetailsInfoItem
title="Transaction fee"
hint="Total transaction fee"
hint={ data.blob_gas_used ? 'Transaction fee without blob fee' : 'Total transaction fee' }
isLoading={ isLoading }
>
{ data.stability_fee ? (
......@@ -422,21 +439,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
) }
</DetailsInfoItem>
) }
{ data.tx_burnt_fee && !config.UI.views.tx.hiddenFields?.burnt_fees && !(rollupFeature.isEnabled && rollupFeature.type === 'optimistic') && (
<DetailsInfoItem
title="Burnt fees"
hint={ `Amount of ${ currencyUnits.ether } burned for this transaction. Equals Block Base Fee per Gas * Gas Used` }
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500"/>
<CurrencyValue
value={ String(data.tx_burnt_fee) }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
ml={ 2 }
/>
</DetailsInfoItem>
) }
<TxDetailsBurntFees data={ data } isLoading={ isLoading }/>
{ rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && (
<>
{ data.l1_gas_used && (
......@@ -502,6 +505,50 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
{ isExpanded && (
<>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
{ (data.blob_gas_used || data.max_fee_per_blob_gas || data.blob_gas_price) && (
<>
{ data.blob_gas_used && data.blob_gas_price && (
<DetailsInfoItem
title="Blob fee"
hint="Blob fee for this transaction"
>
<CurrencyValue
value={ BigNumber(data.blob_gas_used).multipliedBy(data.blob_gas_price).toString() }
currency={ config.UI.views.tx.hiddenFields?.fee_currency ? '' : currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
isLoading={ isLoading }
/>
</DetailsInfoItem>
) }
{ data.blob_gas_used && (
<DetailsInfoItem
title="Blob gas usage"
hint="Amount of gas used by the blobs in this transaction"
>
{ BigNumber(data.blob_gas_used).toFormat() }
</DetailsInfoItem>
) }
{ (data.max_fee_per_blob_gas || data.blob_gas_price) && (
<DetailsInfoItem
title={ `Blob gas fees (${ currencyUnits.gwei })` }
hint={ `Amount of ${ currencyUnits.ether } used for blobs in this transaction` }
>
{ data.blob_gas_price && (
<Text fontWeight="600" as="span">{ BigNumber(data.blob_gas_price).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
) }
{ (data.max_fee_per_blob_gas && data.blob_gas_price) && <TextSeparator/> }
{ data.max_fee_per_blob_gas && (
<>
<Text as="span" fontWeight="500" whiteSpace="pre">Max: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_fee_per_blob_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
</>
) }
</DetailsInfoItem>
) }
<DetailsInfoItemDivider/>
</>
) }
<TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position }/>
<DetailsInfoItem
title="Raw input"
......
......@@ -2,10 +2,9 @@ import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import DetailsActionsWrapper from 'ui/shared/DetailsActionsWrapper';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import TxInterpretation from 'ui/tx/interpretation/TxInterpretation';
import TxDetailsActionsWrapper from './TxDetailsActionsWrapper';
import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation';
interface Props {
hash?: string;
......@@ -30,7 +29,7 @@ const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => {
return (
<>
<TxDetailsActionsWrapper isLoading={ isTxDataLoading || txInterpretationQuery.isPlaceholderData }>
<DetailsActionsWrapper isLoading={ isTxDataLoading || txInterpretationQuery.isPlaceholderData } type="tx">
{ actions.map((action, index: number) => (
<TxInterpretation
key={ index }
......@@ -39,7 +38,7 @@ const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => {
/>
),
) }
</TxDetailsActionsWrapper>
</DetailsActionsWrapper>
<DetailsInfoItemDivider/>
</>
);
......
......@@ -2,10 +2,10 @@ import React from 'react';
import type { TxAction } from 'types/api/txAction';
import DetailsActionsWrapper from 'ui/shared/DetailsActionsWrapper';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import TxDetailsAction from './TxDetailsAction';
import TxDetailsActionsWrapper from './TxDetailsActionsWrapper';
interface Props {
actions: Array<TxAction>;
......@@ -15,9 +15,9 @@ interface Props {
const TxDetailsActionsRaw = ({ actions, isLoading }: Props) => {
return (
<>
<TxDetailsActionsWrapper isLoading={ isLoading }>
<DetailsActionsWrapper isLoading={ isLoading } type="tx">
{ actions.map((action, index: number) => <TxDetailsAction key={ index } action={ action }/>) }
</TxDetailsActionsWrapper>
</DetailsActionsWrapper>
<DetailsInfoItemDivider/>
</>
);
......
......@@ -28,7 +28,7 @@ const TxStateTableItem = ({ data, isLoading }: Props) => {
isLoading={ isLoading }
truncation="constant"
my="7px"
w="min-content"
w="100%"
/>
</Td>
<Td isNumeric><Box py="7px">{ before }</Box></Td>
......
......@@ -10,6 +10,7 @@ import { route } from 'nextjs-routes';
import type { ResourceError } from 'lib/api/resources';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -42,7 +43,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
}, [ data, router ]);
if (isError) {
if (error?.status === 404 || error?.status === 422) {
if (isCustomAppError(error)) {
throwOnResourceLoadError({ isError, error });
}
......@@ -86,7 +87,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
title="Timestamp"
isLoading={ isPlaceholderData }
>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
{ data.timestamp ? <DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/> : 'Undefined' }
</DetailsInfoItem>
<DetailsInfoItem
title="Verify tx hash"
......@@ -98,7 +99,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
hash={ data.verify_tx_hash }
maxW="100%"
/>
) : <Text>pending</Text> }
) : <Text>Pending</Text> }
</DetailsInfoItem>
<DetailsInfoItem
title="Transactions"
......
......@@ -18,7 +18,7 @@ const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean };
const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null;
......@@ -61,7 +61,7 @@ const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => {
</LinkInternal>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Verify Tx Has</ListItemMobileGrid.Label>
<ListItemMobileGrid.Label isLoading={ isLoading }>Verify tx hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
{ item.verify_tx_hash ? (
<TxEntityL1
......
......@@ -22,7 +22,7 @@ const TxnBatchesTable = ({ items, top, isLoading }: Props) => {
<Th width="33%">Status</Th>
<Th width="150px">Age</Th>
<Th width="150px">Txn count</Th>
<Th width="230px">Verify Tx Has</Th>
<Th width="230px">Verify tx hash</Th>
<Th width="230px">Sequence hash</Th>
</Tr>
</Thead>
......
......@@ -17,7 +17,7 @@ const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean };
const TxnBatchesTableItem = ({ item, isLoading }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
const timeAgo = item.timestamp ? dayjs(item.timestamp).fromNow() : 'Undefined';
if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null;
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as txMock from 'mocks/txs/tx';
import TestApp from 'playwright/TestApp';
import TxAdditionalInfo from './TxAdditionalInfo';
test('regular transaction +@dark-mode', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxAdditionalInfo tx={ txMock.base }/>
</TestApp>,
);
await component.getByLabel('Transaction info').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 600 } });
});
test('regular transaction +@mobile -@default', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxAdditionalInfo tx={ txMock.base } isMobile/>
</TestApp>,
);
await component.getByLabel('Transaction info').click();
await expect(page).toHaveScreenshot();
});
test('blob transaction', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxAdditionalInfo tx={ txMock.withBlob }/>
</TestApp>,
);
await component.getByLabel('Transaction info').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 600 } });
});
......@@ -58,7 +58,7 @@ const TxAdditionalInfo = ({ hash, tx, isMobile, isLoading, className }: Props) =
<AdditionalInfoButton isOpen={ isOpen } isLoading={ isLoading } className={ className }/>
</PopoverTrigger>
<PopoverContent border="1px solid" borderColor="divider">
<PopoverBody>
<PopoverBody fontWeight={ 400 } fontSize="sm">
{ content }
</PopoverBody>
</PopoverContent>
......
......@@ -10,6 +10,7 @@ import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import LinkInternal from 'ui/shared/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator';
import TxFeeStability from 'ui/shared/tx/TxFeeStability';
......@@ -26,12 +27,34 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
color: 'gray.500',
fontWeight: 600,
marginBottom: 3,
fontSize: 'sm',
};
return (
<>
<Heading as="h4" size="sm" mb={ 6 }>Additional info </Heading>
{ tx.blob_versioned_hashes && tx.blob_versioned_hashes.length > 0 && (
<Box { ...sectionProps } mb={ 4 }>
<Flex alignItems="center" justifyContent="space-between">
<Text { ...sectionTitleProps }>Blobs: { tx.blob_versioned_hashes.length }</Text>
{ tx.blob_versioned_hashes.length > 3 && (
<LinkInternal
href={ route({ pathname: '/tx/[hash]', query: { hash: tx.hash, tab: 'blobs' } }) }
mb={ 3 }
>
view all
</LinkInternal>
) }
</Flex>
<Flex flexDir="column" rowGap={ 3 }>
{ tx.blob_versioned_hashes.slice(0, 3).map((hash, index) => (
<Flex key={ hash } columnGap={ 2 }>
<Box fontWeight={ 500 }>{ index + 1 }</Box>
<BlobEntity hash={ hash } noIcon/>
</Flex>
)) }
</Flex>
</Box>
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<Box { ...sectionProps } mb={ 4 }>
{ (tx.stability_fee !== undefined || tx.fee.value !== null) && (
......@@ -73,40 +96,42 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
{ tx.base_fee_per_gas !== null && (
<Box>
<Text as="span" fontWeight="500">Base: </Text>
<Text fontWeight="600" as="span">{ getValueWithUnit(tx.base_fee_per_gas, 'gwei').toFormat() }</Text>
<Text fontWeight="700" as="span">{ getValueWithUnit(tx.base_fee_per_gas, 'gwei').toFormat() }</Text>
</Box>
) }
{ tx.max_fee_per_gas !== null && (
<Box mt={ 1 }>
<Text as="span" fontWeight="500">Max: </Text>
<Text fontWeight="600" as="span">{ getValueWithUnit(tx.max_fee_per_gas, 'gwei').toFormat() }</Text>
<Text fontWeight="700" as="span">{ getValueWithUnit(tx.max_fee_per_gas, 'gwei').toFormat() }</Text>
</Box>
) }
{ tx.max_priority_fee_per_gas !== null && (
<Box mt={ 1 }>
<Text as="span" fontWeight="500">Max priority: </Text>
<Text fontWeight="600" as="span">{ getValueWithUnit(tx.max_priority_fee_per_gas, 'gwei').toFormat() }</Text>
<Text fontWeight="700" as="span">{ getValueWithUnit(tx.max_priority_fee_per_gas, 'gwei').toFormat() }</Text>
</Box>
) }
</Box>
) }
<Box { ...sectionProps } mb={ 4 }>
<Text { ...sectionTitleProps }>Others</Text>
<Box>
<Text as="span" fontWeight="500">Txn type: </Text>
<Text fontWeight="600" as="span">{ tx.type }</Text>
{ tx.type === 2 && <Text fontWeight="400" as="span" ml={ 1 } color="gray.500">(EIP-1559)</Text> }
</Box>
<Box mt={ 1 }>
<Text as="span" fontWeight="500">Nonce: </Text>
<Text fontWeight="600" as="span">{ tx.nonce }</Text>
</Box>
<Box mt={ 1 }>
<Text as="span" fontWeight="500">Position: </Text>
<Text fontWeight="600" as="span">{ tx.position }</Text>
{ !(tx.blob_versioned_hashes && tx.blob_versioned_hashes.length > 0) && (
<Box { ...sectionProps } mb={ 4 }>
<Text { ...sectionTitleProps }>Others</Text>
<Box>
<Text as="span" fontWeight="500">Txn type: </Text>
<Text fontWeight="600" as="span">{ tx.type }</Text>
{ tx.type === 2 && <Text fontWeight="400" as="span" ml={ 1 } color="gray.500">(EIP-1559)</Text> }
</Box>
<Box mt={ 1 }>
<Text as="span" fontWeight="500">Nonce: </Text>
<Text fontWeight="600" as="span">{ tx.nonce }</Text>
</Box>
<Box mt={ 1 }>
<Text as="span" fontWeight="500">Position: </Text>
<Text fontWeight="600" as="span">{ tx.position }</Text>
</Box>
</Box>
</Box>
<LinkInternal fontSize="sm" href={ route({ pathname: '/tx/[hash]', query: { hash: tx.hash } }) }>More details</LinkInternal>
) }
<LinkInternal href={ route({ pathname: '/tx/[hash]', query: { hash: tx.hash } }) }>More details</LinkInternal>
</>
);
};
......
......@@ -9,7 +9,16 @@ export interface Props {
isLoading?: boolean;
}
const TYPES_ORDER = [ 'rootstock_remasc', 'rootstock_bridge', 'token_creation', 'contract_creation', 'token_transfer', 'contract_call', 'coin_transfer' ];
const TYPES_ORDER: Array<TransactionType> = [
'blob_transaction',
'rootstock_remasc',
'rootstock_bridge',
'token_creation',
'contract_creation',
'token_transfer',
'contract_call',
'coin_transfer',
];
const TxType = ({ types, isLoading }: Props) => {
const typeToShow = types.sort((t1, t2) => TYPES_ORDER.indexOf(t1) - TYPES_ORDER.indexOf(t2))[0];
......@@ -22,6 +31,10 @@ const TxType = ({ types, isLoading }: Props) => {
label = 'Contract call';
colorScheme = 'blue';
break;
case 'blob_transaction':
label = 'Blob txn';
colorScheme = 'yellow';
break;
case 'contract_creation':
label = 'Contract creation';
colorScheme = 'blue';
......
import type { Transaction } from 'types/api/transaction';
import sortTxs, { sortTxsFromSocket } from './sortTxs';
describe('sortTxs', () => {
it('should sort transactions by value in descending order', () => {
const txs = [
{ value: '42' },
{ value: '11' },
{ value: '24' },
] as Array<Transaction>;
const result = txs.sort(sortTxs('value-desc'));
expect(result).toEqual([
{ value: '42' },
{ value: '24' },
{ value: '11' },
]);
});
it('should sort transactions by value in ascending order', () => {
const txs = [
{ value: '42' },
{ value: '11' },
{ value: '24' },
] as Array<Transaction>;
const result = txs.sort(sortTxs('value-asc'));
expect(result).toEqual([
{ value: '11' },
{ value: '24' },
{ value: '42' },
]);
});
it('should sort transactions by fee in descending order', () => {
const txs = [
{ fee: { value: '42' } },
{ fee: { value: '11' } },
{ fee: { value: '24' } },
] as Array<Transaction>;
const result = txs.sort(sortTxs('fee-desc'));
expect(result).toEqual([
{ fee: { value: '42' } },
{ fee: { value: '24' } },
{ fee: { value: '11' } },
]);
});
it('should sort transactions by fee in ascending order', () => {
const txs = [
{ fee: { value: '42' } },
{ fee: { value: '11' } },
{ fee: { value: '24' } },
] as Array<Transaction>;
const result = txs.sort(sortTxs('fee-asc'));
expect(result).toEqual([
{ fee: { value: '11' } },
{ fee: { value: '24' } },
{ fee: { value: '42' } },
]);
});
});
describe('sortTxsFromSocket', () => {
it('should sort transaction by age in ascending order if sorting is not provided', () => {
const txs = [
{ timestamp: '2022-11-01T12:33:00Z' },
{ timestamp: '2022-11-01T12:00:00Z' },
{ timestamp: null },
{ timestamp: '2022-11-03T03:03:00Z' },
] as Array<Transaction>;
const result = txs.sort(sortTxsFromSocket(undefined));
expect(result).toEqual([
{ timestamp: null },
{ timestamp: '2022-11-03T03:03:00Z' },
{ timestamp: '2022-11-01T12:33:00Z' },
{ timestamp: '2022-11-01T12:00:00Z' },
]);
});
});
import type { Transaction, TransactionsSortingValue } from 'types/api/transaction';
import compareBns from 'lib/bigint/compareBns';
export default function sortTxs(sorting: TransactionsSortingValue | undefined) {
return function sortingFn(tx1: Transaction, tx2: Transaction) {
switch (sorting) {
case 'value-desc':
return compareBns(tx2.value, tx1.value);
case 'value-asc':
return compareBns(tx1.value, tx2.value);
case 'fee-desc':
return compareBns(tx2.fee.value || 0, tx1.fee.value || 0);
case 'fee-asc':
return compareBns(tx1.fee.value || 0, tx2.fee.value || 0);
default:
return 0;
}
};
}
export function sortTxsFromSocket(sorting: TransactionsSortingValue | undefined) {
if (sorting) {
return sortTxs(sorting);
}
return function sortingFn(tx1: Transaction, tx2: Transaction) {
if (!tx1.timestamp) {
return -1;
}
if (!tx2.timestamp) {
return 1;
}
return tx2.timestamp.localeCompare(tx1.timestamp);
};
}
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { Transaction, TransactionsSortingValue, TxsResponse } from 'types/api/transaction';
import type { TransactionsSortingValue, TxsResponse } from 'types/api/transaction';
import type { ResourceError } from 'lib/api/resources';
import compareBns from 'lib/bigint/compareBns';
import * as cookies from 'lib/cookies';
import type { Option } from 'ui/shared/sort/Sort';
import sortTxs from './sortTxs';
export const SORT_OPTIONS: Array<Option<TransactionsSortingValue>> = [
{ title: 'Default', id: undefined },
{ title: 'Value ascending', id: 'value-asc' },
......@@ -23,21 +24,6 @@ type HookResult = UseQueryResult<TxsResponse, ResourceError<unknown>> & {
setSortByValue: (value: SortingValue) => void;
}
const sortTxs = (sorting: SortingValue) => (tx1: Transaction, tx2: Transaction) => {
switch (sorting) {
case 'value-desc':
return compareBns(tx1.value, tx2.value);
case 'value-asc':
return compareBns(tx2.value, tx1.value);
case 'fee-desc':
return compareBns(tx1.fee.value || 0, tx2.fee.value || 0);
case 'fee-asc':
return compareBns(tx2.fee.value || 0, tx1.fee.value || 0);
default:
return 0;
}
};
export default function useTxsSort(
queryResult: UseQueryResult<TxsResponse, ResourceError<unknown>>,
): HookResult {
......
......@@ -12,6 +12,7 @@ import { WEI, WEI_IN_GWEI } from 'lib/consts';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -26,6 +27,8 @@ import UserOpSponsorType from 'ui/shared/userOps/UserOpSponsorType';
import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
import Utilization from 'ui/shared/Utilization/Utilization';
import UserOpDetailsActions from './UserOpDetailsActions';
interface Props {
query: UseQueryResult<UserOp, ResourceError>;
}
......@@ -46,7 +49,7 @@ const UserOpDetails = ({ query }: Props) => {
}, []);
if (isError) {
if (error?.status === 400 || error?.status === 404 || error?.status === 422) {
if (error?.status === 400 || isCustomAppError(error)) {
throwOnResourceLoadError({ isError, error });
}
......@@ -168,6 +171,8 @@ const UserOpDetails = ({ query }: Props) => {
<AddressStringOrParam address={ data.entry_point } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
{ config.features.txInterpretation.isEnabled && <UserOpDetailsActions hash={ data.hash } isUserOpDataLoading={ isPlaceholderData }/> }
{ /* CUT */ }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Element name={ CUT_LINK_NAME }>
......
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import DetailsActionsWrapper from 'ui/shared/DetailsActionsWrapper';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation';
interface Props {
hash?: string;
isUserOpDataLoading: boolean;
}
const TxDetailsActionsInterpretation = ({ hash, isUserOpDataLoading }: Props) => {
const interpretationQuery = useApiQuery('user_op_interpretation', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash) && !isUserOpDataLoading,
placeholderData: TX_INTERPRETATION,
refetchOnMount: false,
},
});
const actions = interpretationQuery.data?.data.summaries;
if (!actions || actions.length < 2) {
return null;
}
return (
<>
<DetailsActionsWrapper isLoading={ isUserOpDataLoading || interpretationQuery.isPlaceholderData } type="user_op">
{ actions.map((action, index: number) => (
<TxInterpretation
key={ index }
summary={ action }
isLoading={ isUserOpDataLoading || interpretationQuery.isPlaceholderData }
/>
),
) }
</DetailsActionsWrapper>
<DetailsInfoItemDivider/>
</>
);
};
export default TxDetailsActionsInterpretation;
......@@ -7,7 +7,7 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet';
// order is taken from the ERC-4337 standard
// eslint-disable-next-line max-len
const KEYS_ORDER: Array<keyof UserOp['raw']> = [ 'sender', 'nonce', 'init_code', 'call_data', 'call_gas_limit', 'verification_gas_limit', 'pre_verification_gas', 'max_fee_per_gas', 'max_priority_fee_per_gas', 'paymaster_and_data', 'signature' ];
const KEYS_ORDER: Array<keyof UserOp['raw']> = [ 'sender', 'nonce', 'init_code', 'call_data', 'account_gas_limits', 'call_gas_limit', 'verification_gas_limit', 'pre_verification_gas', 'gas_fees', 'max_fee_per_gas', 'max_priority_fee_per_gas', 'paymaster_and_data', 'signature' ];
interface Props {
rawData?: UserOp['raw'];
......@@ -20,7 +20,10 @@ const UserOpRaw = ({ rawData, isLoading }: Props) => {
}
const text = JSON.stringify(KEYS_ORDER.reduce((res: UserOp['raw'], key: keyof UserOp['raw']) => {
res[key] = rawData[key];
const value = rawData[key];
if (value !== undefined) {
res[key] = value;
}
return res;
}, {} as UserOp['raw']), undefined, 4);
......
import { Flex, Link } from '@chakra-ui/react';
// import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
// import type { UserOp } from 'types/api/userOps';
import config from 'configs/app';
// import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import { TX_ACTIONS_BLOCK_ID } from 'ui/shared/DetailsActionsWrapper';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation';
type Props = {
hash?: string;
// userOpQuery: UseQueryResult<UserOp, ResourceError<unknown>>;
}
const UserOpSubHeading = ({ hash }: Props) => {
const hasInterpretationFeature = config.features.txInterpretation.isEnabled;
const txInterpretationQuery = useApiQuery('user_op_interpretation', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash) && hasInterpretationFeature,
placeholderData: TX_INTERPRETATION,
},
});
const hasInterpretation = hasInterpretationFeature &&
(txInterpretationQuery.isPlaceholderData || Boolean(txInterpretationQuery.data?.data.summaries.length));
const hasViewAllInterpretationsLink =
!txInterpretationQuery.isPlaceholderData && txInterpretationQuery.data?.data.summaries && txInterpretationQuery.data?.data.summaries.length > 1;
if (hasInterpretation) {
return (
<Flex mr={{ base: 0, lg: 6 }} flexWrap="wrap" alignItems="center">
<TxInterpretation
summary={ txInterpretationQuery.data?.data.summaries[0] }
isLoading={ txInterpretationQuery.isPlaceholderData }
fontSize="lg"
mr={ hasViewAllInterpretationsLink ? 3 : 0 }
/>
{ hasViewAllInterpretationsLink &&
<Link href={ `#${ TX_ACTIONS_BLOCK_ID }` }>View all</Link> }
</Flex>
);
// fallback will be added later
// } else if (hasInterpretationFeature && userOpQuery.data?.decoded_call_data.method_call && userOpQuery.data?.sender && userOpQuery.data?.to) {
// return (
// <TxInterpretation
// summary={{
// summary_template: `{sender_hash} called {method} on {receiver_hash}`,
// summary_template_variables: {
// sender_hash: {
// type: 'address',
// value: txQuery.data.from,
// },
// method: {
// type: 'method',
// value: txQuery.data.method,
// },
// receiver_hash: {
// type: 'address',
// value: txQuery.data.to,
// },
// },
// }}
// isLoading={ txQuery.isPlaceholderData }
// fontSize="lg"
// />
// );
} else {
return <UserOpEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } fontFamily="heading"/>;
}
};
export default UserOpSubHeading;
......@@ -36,7 +36,7 @@ const ValidatorsFilter = ({ onChange, defaultValue, isActive }: Props) => {
<MenuItemOption value="all">All</MenuItemOption>
<MenuItemOption value="active">Active</MenuItemOption>
<MenuItemOption value="probation">Probation</MenuItemOption>
<MenuItemOption value="inactive">Failed</MenuItemOption>
<MenuItemOption value="inactive">Inactive</MenuItemOption>
</MenuOptionGroup>
</MenuList>
</Menu>
......
......@@ -29,7 +29,11 @@ const OptimisticL2WithdrawalsTable = ({ items, top, isLoading }: Props) => {
</Thead>
<Tbody>
{ items.map((item, index) => (
<OptimisticL2WithdrawalsTableItem key={ item.l2_tx_hash + (isLoading ? index : '') } item={ item } isLoading={ isLoading }/>
<OptimisticL2WithdrawalsTableItem
key={ String(item.msg_nonce_version) + item.msg_nonce + (isLoading ? index : '') }
item={ item }
isLoading={ isLoading }
/>
)) }
</Tbody>
</Table>
......
......@@ -7635,6 +7635,15 @@ axios@^1.4.0:
form-data "^4.0.0"
proxy-from-env "^1.1.0"
axios@^1.6.2:
version "1.6.7"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7"
integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==
dependencies:
follow-redirects "^1.15.4"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
axobject-query@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
......@@ -8220,6 +8229,11 @@ comment-parser@^1.1.2:
resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.3.1.tgz#3d7ea3adaf9345594aedee6563f422348f165c1b"
integrity sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==
common-tags@1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6"
integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==
compute-scroll-into-view@1.0.20:
version "1.0.20"
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz#1768b5522d1172754f5d0c9b02de3af6be506a43"
......@@ -8319,7 +8333,7 @@ cross-fetch@^3.1.4, cross-fetch@^3.1.5:
dependencies:
node-fetch "2.6.7"
cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
......@@ -9969,6 +9983,11 @@ follow-redirects@^1.15.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
follow-redirects@^1.15.4:
version "1.15.5"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020"
integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==
fontfaceobserver@2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fontfaceobserver/-/fontfaceobserver-2.1.0.tgz#e2705d293e2c585a6531c2a722905657317a2991"
......@@ -10106,6 +10125,13 @@ gcp-metadata@^5.0.0:
gaxios "^5.0.0"
json-bigint "^1.0.0"
generic-names@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-4.0.0.tgz#0bd8a2fd23fe8ea16cbd0a279acd69c06933d9a3"
integrity sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==
dependencies:
loader-utils "^3.2.0"
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
......@@ -10167,6 +10193,15 @@ get-tsconfig@^4.5.0:
resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.5.0.tgz#6d52d1c7b299bd3ee9cd7638561653399ac77b0f"
integrity sha512-MjhiaIWCJ1sAU4pIQ5i5OfOuHHxVo1oYeNsWTON7jxYkod8pHocXeh+SSbmu5OZZZK73B6cbJ2XADzXehLyovQ==
getit-sdk@^1.0.4:
version "1.0.13"
resolved "https://registry.yarnpkg.com/getit-sdk/-/getit-sdk-1.0.13.tgz#702cd2e8dfeb42884deed6d8d7fbac9af413c73a"
integrity sha512-gWDJZNMoy7Uw9S3qOvQ+gVFdmrHuI17ePGhJYbP25DGOzgCwD5n7KDy9JWxGpLqEnf+V3v3aoyTvP8TtZheEaA==
dependencies:
axios "^1.6.2"
jest-transform-css "^6.0.1"
tslib "^2.6.2"
github-from-package@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
......@@ -10577,6 +10612,11 @@ iconv-lite@0.6, iconv-lite@0.6.3:
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
icss-replace-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
integrity sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==
icss-utils@^5.0.0, icss-utils@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae"
......@@ -11422,6 +11462,17 @@ jest-snapshot@^29.3.1:
pretty-format "^29.3.1"
semver "^7.3.5"
jest-transform-css@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/jest-transform-css/-/jest-transform-css-6.0.1.tgz#95c579c98945734439c1f243b9843d5f477a9c60"
integrity sha512-i78Pi2MW6vcdsUFSRx1kPbjbEIO0pBWwh1Y+PcDrLwTv/6e5p7fzsV/gxFW/SYMHS8DUvMdRVTwVCkA/y+t0iQ==
dependencies:
common-tags "1.8.2"
cross-spawn "7.0.3"
postcss-load-config "4.0.1"
postcss-modules "4.3.1"
style-inject "0.3.0"
jest-util@^29.0.0, jest-util@^29.3.1:
version "29.3.1"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.3.1.tgz#1dda51e378bbcb7e3bc9d8ab651445591ed373e1"
......@@ -11751,6 +11802,11 @@ lilconfig@2.0.5:
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.5.tgz#19e57fd06ccc3848fd1891655b5a447092225b25"
integrity sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==
lilconfig@^2.0.5:
version "2.1.0"
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52"
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
lines-and-columns@^1.1.6:
version "1.2.4"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
......@@ -11876,6 +11932,11 @@ lit@3.1.0:
lit-element "^4.0.0"
lit-html "^3.1.0"
loader-utils@^3.2.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.1.tgz#4fb104b599daafd82ef3e1a41fb9265f87e1f576"
integrity sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==
locate-path@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
......@@ -11999,6 +12060,11 @@ lz-string@^1.5.0:
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
magic-bytes.js@1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/magic-bytes.js/-/magic-bytes.js-1.8.0.tgz#8362793c60cd77c2dd77db6420be727192df68e2"
integrity sha512-lyWpfvNGVb5lu8YUAbER0+UMBTdR63w2mcSUlhhBTyVbxJvjgqwyAf3AZD6MprgK0uHuBoWXSDAMWLupX83o3Q==
magic-string@^0.30.5:
version "0.30.5"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9"
......@@ -13035,24 +13101,32 @@ popmotion@11.0.3:
style-value-types "5.0.0"
tslib "^2.1.0"
postcss-load-config@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.1.tgz#152383f481c2758274404e4962743191d73875bd"
integrity sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==
dependencies:
lilconfig "^2.0.5"
yaml "^2.1.1"
postcss-modules-extract-imports@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d"
integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==
postcss-modules-local-by-default@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c"
integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==
version "4.0.4"
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.4.tgz#7cbed92abd312b94aaea85b68226d3dec39a14e6"
integrity sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==
dependencies:
icss-utils "^5.0.0"
postcss-selector-parser "^6.0.2"
postcss-value-parser "^4.1.0"
postcss-modules-scope@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06"
integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==
version "3.1.1"
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.1.1.tgz#32cfab55e84887c079a19bbb215e721d683ef134"
integrity sha512-uZgqzdTleelWjzJY+Fhti6F3C9iF1JR/dODLs/JDefozYcKTBCdD8BIl6nNPbTbcLnGrk56hzwZC2DaGNvYjzA==
dependencies:
postcss-selector-parser "^6.0.4"
......@@ -13063,10 +13137,24 @@ postcss-modules-values@^4.0.0:
dependencies:
icss-utils "^5.0.0"
postcss-modules@4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/postcss-modules/-/postcss-modules-4.3.1.tgz#517c06c09eab07d133ae0effca2c510abba18048"
integrity sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==
dependencies:
generic-names "^4.0.0"
icss-replace-symbols "^1.1.0"
lodash.camelcase "^4.3.0"
postcss-modules-extract-imports "^3.0.0"
postcss-modules-local-by-default "^4.0.0"
postcss-modules-scope "^3.0.0"
postcss-modules-values "^4.0.0"
string-hash "^1.1.1"
postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4:
version "6.0.11"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz#2e41dc39b7ad74046e1615185185cd0b17d0c8dc"
integrity sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==
version "6.0.15"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz#11cc2b21eebc0b99ea374ffb9887174855a01535"
integrity sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==
dependencies:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
......@@ -14389,7 +14477,7 @@ string-argv@^0.3.1:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
string-hash@^1.1.3:
string-hash@^1.1.1, string-hash@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b"
integrity sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==
......@@ -14547,6 +14635,11 @@ strip-json-comments@~2.0.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
style-inject@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/style-inject/-/style-inject-0.3.0.tgz#d21c477affec91811cc82355832a700d22bf8dd3"
integrity sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==
style-loader@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.1.tgz#057dfa6b3d4d7c7064462830f9113ed417d38575"
......@@ -15021,6 +15114,11 @@ tslib@^2.3.0, tslib@^2.5.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==
tslib@^2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
......@@ -15721,9 +15819,9 @@ yaml@^1.10.0:
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.1.3.tgz#9b3a4c8aff9821b696275c79a8bee8399d945207"
integrity sha512-AacA8nRULjKMX2DvWvOAdBZMOfQlypSFkjcOcu9FalllIDJ1kvlREzcdIZmidQUqqeMv7jorHjq2HlLv/+c2lg==
version "2.4.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed"
integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==
yaml@^2.2.2:
version "2.3.1"
......
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