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: ...@@ -95,6 +95,8 @@ jobs:
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
...@@ -115,7 +117,7 @@ jobs: ...@@ -115,7 +117,7 @@ jobs:
run: yarn --frozen-lockfile --ignore-optional run: yarn --frozen-lockfile --ignore-optional
- name: Run Jest - 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: pw_affected_tests:
name: Resolve affected Playwright tests name: Resolve affected Playwright tests
......
...@@ -11,6 +11,7 @@ export { default as googleAnalytics } from './googleAnalytics'; ...@@ -11,6 +11,7 @@ export { default as googleAnalytics } from './googleAnalytics';
export { default as graphqlApiDocs } from './graphqlApiDocs'; export { default as graphqlApiDocs } from './graphqlApiDocs';
export { default as growthBook } from './growthBook'; export { default as growthBook } from './growthBook';
export { default as marketplace } from './marketplace'; export { default as marketplace } from './marketplace';
export { default as metasuites } from './metasuites';
export { default as mixpanel } from './mixpanel'; export { default as mixpanel } from './mixpanel';
export { default as nameService } from './nameService'; export { default as nameService } from './nameService';
export { default as restApiDocs } from './restApiDocs'; 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 type { TxAdditionalFieldsId, TxFieldsId, TxViewId } from 'types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } 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'; import { getEnvValue, parseEnvJson } from 'configs/app/utils';
...@@ -33,9 +33,31 @@ const additionalFields = (() => { ...@@ -33,9 +33,31 @@ const additionalFields = (() => {
return result; 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({ const config = Object.freeze({
hiddenFields, hiddenFields,
additionalFields, additionalFields,
hiddenViews,
}); });
export default config; export default config;
...@@ -46,6 +46,12 @@ NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout ...@@ -46,6 +46,12 @@ NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_AD_BANNER_PROVIDER=hype NEXT_PUBLIC_AD_BANNER_PROVIDER=hype
NEXT_PUBLIC_SAFE_TX_SERVICE_URL=https://safe-transaction-mainnet.safe.global 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_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 #meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true 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' ...@@ -53,6 +53,7 @@ NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true'
NEXT_PUBLIC_HAS_BEACON_CHAIN=true NEXT_PUBLIC_HAS_BEACON_CHAIN=true
NEXT_PUBLIC_HAS_USER_OPS=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_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 #meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth-goerli.png?raw=true 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 ...@@ -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_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
# rollup # rollup
NEXT_PUBLIC_IS_OPTIMISTIC_L2_NETWORK=true NEXT_PUBLIC_ROLLUP_TYPE='optimistic'
NEXT_PUBLIC_OPTIMISTIC_L2_WITHDRAWAL_URL=https://app.optimism.io/bridge/withdraw NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL=https://app.optimism.io/bridge/withdraw
NEXT_PUBLIC_L1_BASE_URL=https://eth-goerli.blockscout.com/ 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 ...@@ -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_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED=true
NEXT_PUBLIC_HAS_BEACON_CHAIN=true NEXT_PUBLIC_HAS_BEACON_CHAIN=true
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_AD_BANNER_PROVIDER=getit
#meta #meta
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/sepolia-testnet.png 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 ...@@ -32,8 +32,8 @@ import { ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address
import { BLOCK_FIELDS_IDS } from '../../../types/views/block'; import { BLOCK_FIELDS_IDS } from '../../../types/views/block';
import type { BlockFieldId } from '../../../types/views/block'; import type { BlockFieldId } from '../../../types/views/block';
import type { NftMarketplaceItem } from '../../../types/views/nft'; import type { NftMarketplaceItem } from '../../../types/views/nft';
import type { TxAdditionalFieldsId, TxFieldsId } from '../../../types/views/tx'; import type { TxAdditionalFieldsId, TxFieldsId, TxViewId } from '../../../types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } 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 { replaceQuotes } from '../../../configs/app/utils';
import * as regexp from '../../../lib/regexp'; import * as regexp from '../../../lib/regexp';
...@@ -457,6 +457,11 @@ const schema = yup ...@@ -457,6 +457,11 @@ const schema = yup
.transform(replaceQuotes) .transform(replaceQuotes)
.json() .json()
.of(yup.string<TxAdditionalFieldsId>().oneOf(TX_ADDITIONAL_FIELDS_IDS)), .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 NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: yup
.array() .array()
.transform(replaceQuotes) .transform(replaceQuotes)
...@@ -507,6 +512,7 @@ const schema = yup ...@@ -507,6 +512,7 @@ const schema = yup
NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest), NEXT_PUBLIC_SAFE_TX_SERVICE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(), NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(), NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
NEXT_PUBLIC_METASUITES_ENABLED: yup.boolean(),
NEXT_PUBLIC_SWAP_BUTTON_URL: yup.string(), NEXT_PUBLIC_SWAP_BUTTON_URL: yup.string(),
NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE: yup.string<ValidatorsChainType>().oneOf(VALIDATORS_CHAIN_TYPE), NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE: yup.string<ValidatorsChainType>().oneOf(VALIDATORS_CHAIN_TYPE),
NEXT_PUBLIC_GAS_TRACKER_ENABLED: yup.boolean(), NEXT_PUBLIC_GAS_TRACKER_ENABLED: yup.boolean(),
......
...@@ -24,6 +24,7 @@ NEXT_PUBLIC_GAS_TRACKER_ENABLED=true ...@@ -24,6 +24,7 @@ NEXT_PUBLIC_GAS_TRACKER_ENABLED=true
NEXT_PUBLIC_GAS_TRACKER_UNITS=['gwei'] NEXT_PUBLIC_GAS_TRACKER_UNITS=['gwei']
NEXT_PUBLIC_IS_TESTNET=true NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE='<a href="#">Hello</a>' NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE='<a href="#">Hello</a>'
NEXT_PUBLIC_METASUITES_ENABLED=true
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
...@@ -49,6 +50,7 @@ NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=['burnt_fees','total_reward'] ...@@ -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_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_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_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_VISUALIZE_API_HOST=https://example.com
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false
NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket'] NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket']
......
...@@ -30,10 +30,12 @@ frontend: ...@@ -30,10 +30,12 @@ frontend:
kubernetes.io/ingress.class: internal-and-public kubernetes.io/ingress.class: internal-and-public
nginx.ingress.kubernetes.io/proxy-body-size: 500m nginx.ingress.kubernetes.io/proxy-body-size: 500m
nginx.ingress.kubernetes.io/client-max-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-connect-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-send-timeout: "15m" nginx.ingress.kubernetes.io/proxy-send-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-read-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" cert-manager.io/cluster-issuer: "zerossl-prod"
hostname: review-l2-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }}.k8s-dev.blockscout.com hostname: review-l2-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }}.k8s-dev.blockscout.com
......
...@@ -30,10 +30,12 @@ frontend: ...@@ -30,10 +30,12 @@ frontend:
kubernetes.io/ingress.class: internal-and-public kubernetes.io/ingress.class: internal-and-public
nginx.ingress.kubernetes.io/proxy-body-size: 500m nginx.ingress.kubernetes.io/proxy-body-size: 500m
nginx.ingress.kubernetes.io/client-max-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-connect-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-send-timeout: "15m" nginx.ingress.kubernetes.io/proxy-send-timeout: "15m"
nginx.ingress.kubernetes.io/proxy-read-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" cert-manager.io/cluster-issuer: "zerossl-prod"
hostname: review-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }}.k8s-dev.blockscout.com hostname: review-{{ requiredEnv "GITHUB_REF_NAME_SLUG" }}.k8s-dev.blockscout.com
...@@ -51,7 +53,7 @@ frontend: ...@@ -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_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_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg NEXT_PUBLIC_NETWORK_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_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_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com
...@@ -81,6 +83,7 @@ frontend: ...@@ -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_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_SWAP_BUTTON_URL: uniswap
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: true NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: true
NEXT_PUBLIC_AD_BANNER_PROVIDER: getit
envFromSecret: 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 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 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 ...@@ -52,6 +52,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Bridged tokens](ENVS.md#bridged-tokens) - [Bridged tokens](ENVS.md#bridged-tokens)
- [Safe{Core} address tags](ENVS.md#safecore-address-tags) - [Safe{Core} address tags](ENVS.md#safecore-address-tags)
- [SUAVE chain](ENVS.md#suave-chain) - [SUAVE chain](ENVS.md#suave-chain)
- [MetaSuites extension](ENVS.md#metasuites-extension)
- [Sentry error monitoring](ENVS.md#sentry-error-monitoring) - [Sentry error monitoring](ENVS.md#sentry-error-monitoring)
- [OpenTelemetry](ENVS.md#opentelemetry) - [OpenTelemetry](ENVS.md#opentelemetry)
- [Swap button](ENVS.md#swap-button) - [Swap button](ENVS.md#swap-button)
...@@ -218,6 +219,7 @@ Settings for meta tags and OG tags ...@@ -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_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_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 ##### Transaction fields list
| Id | Description | | Id | Description |
...@@ -234,6 +236,11 @@ Settings for meta tags and OG tags ...@@ -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 | | `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; &nbsp;
#### NFT views #### NFT views
...@@ -344,7 +351,7 @@ This feature is **enabled by default** with the `slise` ads provider. To switch ...@@ -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 | | 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_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'}` | | 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 ...@@ -590,6 +597,16 @@ For blockchains that implement SUAVE architecture additional fields will be show
&nbsp; &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 ### Validators list
The feature enables the Validators page which provides detailed information about the validators of the PoS chains. 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 { ...@@ -31,6 +31,7 @@ import type {
AddressNFTTokensFilter, AddressNFTTokensFilter,
} from 'types/api/address'; } from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses'; 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 { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts'; import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig } from 'types/api/configs'; import type { BackendVersionConfig } from 'types/api/configs';
...@@ -84,9 +85,10 @@ import type { ...@@ -84,9 +85,10 @@ import type {
Transaction, Transaction,
TransactionsResponseWatchlist, TransactionsResponseWatchlist,
TransactionsSorting, TransactionsSorting,
TransactionsResponseWithBlobs,
} from 'types/api/transaction'; } from 'types/api/transaction';
import type { TxInterpretationResponse } from 'types/api/txInterpretation'; 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 { TxStateChanges } from 'types/api/txStateChanges';
import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps'; import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps';
import type { ValidatorsCountersResponse, ValidatorsFilters, ValidatorsResponse, ValidatorsSorting } from 'types/api/validators'; import type { ValidatorsCountersResponse, ValidatorsFilters, ValidatorsResponse, ValidatorsSorting } from 'types/api/validators';
...@@ -264,7 +266,7 @@ export const RESOURCES = { ...@@ -264,7 +266,7 @@ export const RESOURCES = {
block_txs: { block_txs: {
path: '/api/v2/blocks/:height_or_hash/transactions', path: '/api/v2/blocks/:height_or_hash/transactions',
pathParams: [ 'height_or_hash' as const ], pathParams: [ 'height_or_hash' as const ],
filterFields: [], filterFields: [ 'type' as const ],
}, },
block_withdrawals: { block_withdrawals: {
path: '/api/v2/blocks/:height_or_hash/withdrawals', path: '/api/v2/blocks/:height_or_hash/withdrawals',
...@@ -279,6 +281,10 @@ export const RESOURCES = { ...@@ -279,6 +281,10 @@ export const RESOURCES = {
path: '/api/v2/transactions', path: '/api/v2/transactions',
filterFields: [ 'filter' as const, 'type' as const, 'method' as const ], filterFields: [ 'filter' as const, 'type' as const, 'method' as const ],
}, },
txs_with_blobs: {
path: '/api/v2/transactions',
filterFields: [ 'type' as const ],
},
txs_watchlist: { txs_watchlist: {
path: '/api/v2/transactions/watchlist', path: '/api/v2/transactions/watchlist',
filterFields: [ ], filterFields: [ ],
...@@ -316,6 +322,10 @@ export const RESOURCES = { ...@@ -316,6 +322,10 @@ export const RESOURCES = {
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
filterFields: [], filterFields: [],
}, },
tx_blobs: {
path: '/api/v2/transactions/:hash/blobs',
pathParams: [ 'hash' as const ],
},
tx_interpretation: { tx_interpretation: {
path: '/api/v2/transactions/:hash/summary', path: '/api/v2/transactions/:hash/summary',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
...@@ -652,6 +662,10 @@ export const RESOURCES = { ...@@ -652,6 +662,10 @@ export const RESOURCES = {
path: '/api/v2/proxy/account-abstraction/accounts/:hash', path: '/api/v2/proxy/account-abstraction/accounts/:hash',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
}, },
user_op_interpretation: {
path: '/api/v2/proxy/account-abstraction/operations/:hash/summary',
pathParams: [ 'hash' as const ],
},
// VALIDATORS // VALIDATORS
validators: { validators: {
...@@ -664,6 +678,12 @@ export const RESOURCES = { ...@@ -664,6 +678,12 @@ export const RESOURCES = {
pathParams: [ 'chainType' as const ], pathParams: [ 'chainType' as const ],
}, },
// BLOBS
blob: {
path: '/api/v2/blobs/:hash',
pathParams: [ 'hash' as const ],
},
// CONFIGS // CONFIGS
config_backend_version: { config_backend_version: {
path: '/api/v2/config/backend-version', path: '/api/v2/config/backend-version',
...@@ -723,8 +743,8 @@ export interface ResourceError<T = unknown> { ...@@ -723,8 +743,8 @@ export interface ResourceError<T = unknown> {
export type ResourceErrorAccount<T> = ResourceError<{ errors: T }> export type ResourceErrorAccount<T> = ResourceError<{ errors: T }>
export type PaginatedResources = 'blocks' | 'block_txs' | export type PaginatedResources = 'blocks' | 'block_txs' |
'txs_validated' | 'txs_pending' | 'txs_watchlist' | 'txs_execution_node' | 'txs_validated' | 'txs_pending' | 'txs_with_blobs' | 'txs_watchlist' | 'txs_execution_node' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_blobs' |
'addresses' | 'addresses' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' | 'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' | 'search' |
...@@ -775,6 +795,7 @@ Q extends 'block_txs' ? BlockTransactionsResponse : ...@@ -775,6 +795,7 @@ Q extends 'block_txs' ? BlockTransactionsResponse :
Q extends 'block_withdrawals' ? BlockWithdrawalsResponse : Q extends 'block_withdrawals' ? BlockWithdrawalsResponse :
Q extends 'txs_validated' ? TransactionsResponseValidated : Q extends 'txs_validated' ? TransactionsResponseValidated :
Q extends 'txs_pending' ? TransactionsResponsePending : Q extends 'txs_pending' ? TransactionsResponsePending :
Q extends 'txs_with_blobs' ? TransactionsResponseWithBlobs :
Q extends 'txs_watchlist' ? TransactionsResponseWatchlist : Q extends 'txs_watchlist' ? TransactionsResponseWatchlist :
Q extends 'txs_execution_node' ? TransactionsResponseValidated : Q extends 'txs_execution_node' ? TransactionsResponseValidated :
Q extends 'tx' ? Transaction : Q extends 'tx' ? Transaction :
...@@ -783,6 +804,7 @@ Q extends 'tx_logs' ? LogsResponseTx : ...@@ -783,6 +804,7 @@ Q extends 'tx_logs' ? LogsResponseTx :
Q extends 'tx_token_transfers' ? TokenTransferResponse : Q extends 'tx_token_transfers' ? TokenTransferResponse :
Q extends 'tx_raw_trace' ? RawTracesResponse : Q extends 'tx_raw_trace' ? RawTracesResponse :
Q extends 'tx_state_changes' ? TxStateChanges : Q extends 'tx_state_changes' ? TxStateChanges :
Q extends 'tx_blobs' ? TxBlobs :
Q extends 'tx_interpretation' ? TxInterpretationResponse : Q extends 'tx_interpretation' ? TxInterpretationResponse :
Q extends 'addresses' ? AddressesResponse : Q extends 'addresses' ? AddressesResponse :
Q extends 'address' ? Address : Q extends 'address' ? Address :
...@@ -839,13 +861,6 @@ Q extends 'zkevm_l2_txn_batches_count' ? number : ...@@ -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' ? ZkEvmL2TxnBatch :
Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs : Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs :
Q extends 'config_backend_version' ? BackendVersionConfig : 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; never;
// !!! IMPORTANT !!! // !!! IMPORTANT !!!
// See comment above // See comment above
...@@ -853,6 +868,7 @@ never; ...@@ -853,6 +868,7 @@ never;
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
export type ResourcePayloadB<Q extends ResourceName> = export type ResourcePayloadB<Q extends ResourceName> =
Q extends 'blob' ? Blob :
Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> : Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> :
Q extends 'marketplace_dapp' ? MarketplaceAppOverview : Q extends 'marketplace_dapp' ? MarketplaceAppOverview :
Q extends 'validators' ? ValidatorsResponse : Q extends 'validators' ? ValidatorsResponse :
...@@ -862,6 +878,14 @@ Q extends 'shibarium_deposits' ? ShibariumDepositsResponse : ...@@ -862,6 +878,14 @@ Q extends 'shibarium_deposits' ? ShibariumDepositsResponse :
Q extends 'shibarium_withdrawals_count' ? number : Q extends 'shibarium_withdrawals_count' ? number :
Q extends 'shibarium_deposits_count' ? number : Q extends 'shibarium_deposits_count' ? number :
Q extends 'contract_security_audits' ? SmartContractSecurityAudits : 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; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -874,7 +898,9 @@ export type PaginatedResponseNextPageParams<Q extends ResourceName> = Q extends ...@@ -874,7 +898,9 @@ export type PaginatedResponseNextPageParams<Q extends ResourceName> = Q extends
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
export type PaginationFilters<Q extends PaginatedResources> = export type PaginationFilters<Q extends PaginatedResources> =
Q extends 'blocks' ? BlockFilters : Q extends 'blocks' ? BlockFilters :
Q extends 'block_txs' ? TTxsWithBlobsFilters :
Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters : Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters :
Q extends 'txs_with_blobs' ? TTxsWithBlobsFilters :
Q extends 'tx_token_transfers' ? TokenTransferFilters : Q extends 'tx_token_transfers' ? TokenTransferFilters :
Q extends 'token_transfers' ? TokenTransferFilters : Q extends 'token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters : 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) { export default function hexToBytes(hex: string) {
const bytes = []; 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)); bytes.push(parseInt(hex.substring(c, c + 2), 16));
} }
return bytes; 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> = { ...@@ -37,6 +37,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/output-roots': 'Root page', '/output-roots': 'Root page',
'/batches': 'Root page', '/batches': 'Root page',
'/batches/[number]': 'Regular page', '/batches/[number]': 'Regular page',
'/blobs/[hash]': 'Regular page',
'/ops': 'Root page', '/ops': 'Root page',
'/op/[hash]': 'Regular page', '/op/[hash]': 'Regular page',
'/404': 'Regular page', '/404': 'Regular page',
......
...@@ -40,6 +40,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -40,6 +40,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/output-roots': DEFAULT_TEMPLATE, '/output-roots': DEFAULT_TEMPLATE,
'/batches': DEFAULT_TEMPLATE, '/batches': DEFAULT_TEMPLATE,
'/batches/[number]': DEFAULT_TEMPLATE, '/batches/[number]': DEFAULT_TEMPLATE,
'/blobs/[hash]': DEFAULT_TEMPLATE,
'/ops': DEFAULT_TEMPLATE, '/ops': DEFAULT_TEMPLATE,
'/op/[hash]': DEFAULT_TEMPLATE, '/op/[hash]': DEFAULT_TEMPLATE,
'/404': DEFAULT_TEMPLATE, '/404': DEFAULT_TEMPLATE,
......
...@@ -35,6 +35,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -35,6 +35,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/output-roots': 'output roots', '/output-roots': 'output roots',
'/batches': 'tx batches (L2 blocks)', '/batches': 'tx batches (L2 blocks)',
'/batches/[number]': 'L2 tx batch %number%', '/batches/[number]': 'L2 tx batch %number%',
'/blobs/[hash]': 'blob %hash% details',
'/ops': 'user operations', '/ops': 'user operations',
'/op/[hash]': 'user operation %hash%', '/op/[hash]': 'user operation %hash%',
'/404': 'error - page not found', '/404': 'error - page not found',
......
...@@ -35,6 +35,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -35,6 +35,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/output-roots': 'Output roots', '/output-roots': 'Output roots',
'/batches': 'Tx batches (L2 blocks)', '/batches': 'Tx batches (L2 blocks)',
'/batches/[number]': 'L2 tx batch details', '/batches/[number]': 'L2 tx batch details',
'/blobs/[hash]': 'Blob details',
'/ops': 'User operations', '/ops': 'User operations',
'/op/[hash]': 'User operation details', '/op/[hash]': 'User operation details',
'/404': '404', '/404': '404',
......
...@@ -19,7 +19,7 @@ function formatValue(value: string | number, display: string | undefined, trait: ...@@ -19,7 +19,7 @@ function formatValue(value: string | number, display: string | undefined, trait:
} }
case 'date': { case 'date': {
return { return {
value: dayjs(value).format('YYYY-MM-DD'), value: dayjs(Number(value) * 1000).format('YYYY-MM-DD'),
}; };
} }
default: { default: {
......
...@@ -38,6 +38,17 @@ export const erc20LongSymbol: AddressTokenBalance = { ...@@ -38,6 +38,17 @@ export const erc20LongSymbol: AddressTokenBalance = {
token_instance: null, 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 = { export const erc721a: AddressTokenBalance = {
token: tokens.tokenInfoERC721a, token: tokens.tokenInfoERC721a,
token_id: null, 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 = { ...@@ -135,6 +135,15 @@ export const rootstock: Block = {
minimum_gas_price: '59240000', 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 = { export const baseListResponse: BlocksResponse = {
items: [ items: [
base, base,
......
...@@ -6,6 +6,7 @@ import type { ...@@ -6,6 +6,7 @@ import type {
SearchResultLabel, SearchResultLabel,
SearchResult, SearchResult,
SearchResultUserOp, SearchResultUserOp,
SearchResultBlob,
} from 'types/api/search'; } from 'types/api/search';
export const token1: SearchResultToken = { export const token1: SearchResultToken = {
...@@ -116,6 +117,12 @@ export const userOp1: SearchResultUserOp = { ...@@ -116,6 +117,12 @@ export const userOp1: SearchResultUserOp = {
url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61', url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
}; };
export const blob1: SearchResultBlob = {
blob_hash: '0x0108dd3e414da9f3255f7a831afa606e8dfaea93d082dfa9b15305583cbbdbbe',
type: 'blob' as const,
timestamp: null,
};
export const baseResponse: SearchResult = { export const baseResponse: SearchResult = {
items: [ items: [
token1, token1,
...@@ -124,6 +131,7 @@ export const baseResponse: SearchResult = { ...@@ -124,6 +131,7 @@ export const baseResponse: SearchResult = {
address1, address1,
contract1, contract1,
tx1, tx1,
blob1,
], ],
next_page_params: null, next_page_params: null,
}; };
...@@ -2,18 +2,14 @@ import type { TokenHolders } from 'types/api/token'; ...@@ -2,18 +2,14 @@ import type { TokenHolders } from 'types/api/token';
import { withName, withoutName } from 'mocks/address/address'; import { withName, withoutName } from 'mocks/address/address';
import { tokenInfoERC1155a, tokenInfoERC20a } from './tokenInfo';
export const tokenHoldersERC20: TokenHolders = { export const tokenHoldersERC20: TokenHolders = {
items: [ items: [
{ {
address: withName, address: withName,
token: tokenInfoERC20a,
value: '107014805905725000000', value: '107014805905725000000',
}, },
{ {
address: withoutName, address: withoutName,
token: tokenInfoERC20a,
value: '207014805905725000000', value: '207014805905725000000',
}, },
], ],
...@@ -27,13 +23,11 @@ export const tokenHoldersERC1155: TokenHolders = { ...@@ -27,13 +23,11 @@ export const tokenHoldersERC1155: TokenHolders = {
items: [ items: [
{ {
address: withName, address: withName,
token: tokenInfoERC1155a,
value: '107014805905725000000', value: '107014805905725000000',
token_id: '12345', token_id: '12345',
}, },
{ {
address: withoutName, address: withoutName,
token: tokenInfoERC1155a,
value: '207014805905725000000', value: '207014805905725000000',
token_id: '12345', token_id: '12345',
}, },
......
...@@ -341,3 +341,17 @@ export const base4 = { ...@@ -341,3 +341,17 @@ export const base4 = {
...base, ...base,
hash: '0x22d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193', 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 = { ...@@ -4,3 +4,10 @@ export const base = {
name: 'tom goriunov', name: 'tom goriunov',
nickname: 'tom2drum', 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 { ...@@ -19,6 +19,10 @@ export function ad(): CspDev.DirectiveDescriptor {
// hype // hype
'api.hypelab.com', 'api.hypelab.com',
'*.ixncdn.com', '*.ixncdn.com',
//getit
'v1.getittech.io',
'ipapi.co',
], ],
'frame-src': [ 'frame-src': [
// coinzilla // coinzilla
......
...@@ -28,6 +28,7 @@ declare module "nextjs-routes" { ...@@ -28,6 +28,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/auth/unverified-email"> | StaticRoute<"/auth/unverified-email">
| DynamicRoute<"/batches/[number]", { "number": string }> | DynamicRoute<"/batches/[number]", { "number": string }>
| StaticRoute<"/batches"> | StaticRoute<"/batches">
| DynamicRoute<"/blobs/[hash]", { "hash": string }>
| DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }> | DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }>
| StaticRoute<"/blocks"> | StaticRoute<"/blocks">
| StaticRoute<"/contract-verification"> | StaticRoute<"/contract-verification">
......
...@@ -15,6 +15,7 @@ import { ChakraProvider } from 'lib/contexts/chakra'; ...@@ -15,6 +15,7 @@ import { ChakraProvider } from 'lib/contexts/chakra';
import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection'; import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection';
import { growthBook } from 'lib/growthbook/init'; import { growthBook } from 'lib/growthbook/init';
import useLoadFeatures from 'lib/growthbook/useLoadFeatures'; import useLoadFeatures from 'lib/growthbook/useLoadFeatures';
import useNotifyOnNavigation from 'lib/hooks/useNotifyOnNavigation';
import { SocketProvider } from 'lib/socket/context'; import { SocketProvider } from 'lib/socket/context';
import theme from 'theme'; import theme from 'theme';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary'; import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
...@@ -44,6 +45,7 @@ const ERROR_SCREEN_STYLES: ChakraProps = { ...@@ -44,6 +45,7 @@ const ERROR_SCREEN_STYLES: ChakraProps = {
function MyApp({ Component, pageProps }: AppPropsWithLayout) { function MyApp({ Component, pageProps }: AppPropsWithLayout) {
useLoadFeatures(); useLoadFeatures();
useNotifyOnNavigation();
const queryClient = useQueryClientConfig(); const queryClient = useQueryClientConfig();
......
...@@ -22,11 +22,13 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi ...@@ -22,11 +22,13 @@ export default async function mediaTypeHandler(req: NextApiRequest, res: NextApi
return 'video'; return 'video';
} }
if (contentType?.startsWith('image')) {
return 'image';
}
if (contentType?.startsWith('text/html')) { if (contentType?.startsWith('text/html')) {
return 'html'; return 'html';
} }
return 'image';
})(); })();
res.status(200).json({ type: mediaType }); res.status(200).json({ type: mediaType });
} catch (error) { } 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'; ...@@ -41,6 +41,7 @@ const WALLET_CONNECT_PROJECT_ID = 'PROJECT_ID';
const wagmiConfig = defaultWagmiConfig({ const wagmiConfig = defaultWagmiConfig({
chains, chains,
projectId: WALLET_CONNECT_PROJECT_ID, projectId: WALLET_CONNECT_PROJECT_ID,
enableEmail: true,
}); });
createWeb3Modal({ createWeb3Modal({
......
...@@ -15,9 +15,14 @@ ...@@ -15,9 +15,14 @@
| "arrows/up-down" | "arrows/up-down"
| "beta_xs" | "beta_xs"
| "beta" | "beta"
| "blob"
| "blobs/image"
| "blobs/raw"
| "blobs/text"
| "block_slim" | "block_slim"
| "block" | "block"
| "brands/safe" | "brands/safe"
| "brands/solidity_scan"
| "burger" | "burger"
| "check" | "check"
| "clock-light" | "clock-light"
...@@ -136,6 +141,7 @@ ...@@ -136,6 +141,7 @@
| "txn_batches" | "txn_batches"
| "unfinalized" | "unfinalized"
| "uniswap" | "uniswap"
| "up"
| "user_op_slim" | "user_op_slim"
| "user_op" | "user_op"
| "validator" | "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 = { ...@@ -38,13 +38,11 @@ export const TOKEN_COUNTERS: TokenCounters = {
export const TOKEN_HOLDER_ERC_20: TokenHolder = { export const TOKEN_HOLDER_ERC_20: TokenHolder = {
address: ADDRESS_PARAMS, address: ADDRESS_PARAMS,
token: TOKEN_INFO_ERC_20,
value: '1021378038331138520', value: '1021378038331138520',
}; };
export const TOKEN_HOLDER_ERC_1155: TokenHolder = { export const TOKEN_HOLDER_ERC_1155: TokenHolder = {
address: ADDRESS_PARAMS, address: ADDRESS_PARAMS,
token: TOKEN_INFO_ERC_1155,
token_id: '12345', token_id: '12345',
value: '1021378038331138520', 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 { ...@@ -36,6 +36,12 @@ export interface Block {
bitcoin_merged_mining_merkle_proof?: string | null; bitcoin_merged_mining_merkle_proof?: string | null;
hash_for_merged_mining?: string | null; hash_for_merged_mining?: string | null;
minimum_gas_price?: 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 { export interface BlocksResponse {
......
...@@ -55,6 +55,12 @@ export interface SearchResultTx { ...@@ -55,6 +55,12 @@ export interface SearchResultTx {
url?: string; // not used by the frontend, we build the url ourselves 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 { export interface SearchResultUserOp {
type: 'user_operation'; type: 'user_operation';
user_operation_hash: string; user_operation_hash: string;
...@@ -62,7 +68,8 @@ export interface SearchResultUserOp { ...@@ -62,7 +68,8 @@ export interface SearchResultUserOp {
url?: string; // not used by the frontend, we build the url ourselves 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 { export interface SearchResult {
items: Array<SearchResultItem>; items: Array<SearchResultItem>;
...@@ -86,5 +93,5 @@ export interface SearchResultFilters { ...@@ -86,5 +93,5 @@ export interface SearchResultFilters {
export interface SearchRedirectResult { export interface SearchRedirectResult {
parameter: string | null; parameter: string | null;
redirect: boolean; redirect: boolean;
type: 'address' | 'block' | 'transaction' | 'user_operation' | null; type: 'address' | 'block' | 'transaction' | 'user_operation' | 'blob' | null;
} }
...@@ -39,12 +39,9 @@ export type TokenHolderBase = { ...@@ -39,12 +39,9 @@ export type TokenHolderBase = {
value: string; value: string;
} }
export type TokenHolderERC20ERC721 = TokenHolderBase & { export type TokenHolderERC20ERC721 = TokenHolderBase
token: TokenInfo<'ERC-20'> | TokenInfo<'ERC-721'>;
}
export type TokenHolderERC1155 = TokenHolderBase & { export type TokenHolderERC1155 = TokenHolderBase & {
token: TokenInfo<'ERC-1155'>;
token_id: string; token_id: string;
} }
......
...@@ -79,6 +79,12 @@ export type Transaction = { ...@@ -79,6 +79,12 @@ export type Transaction = {
zkevm_batch_number?: number; zkevm_batch_number?: number;
zkevm_status?: typeof ZKEVM_L2_TX_STATUSES[number]; zkevm_status?: typeof ZKEVM_L2_TX_STATUSES[number];
zkevm_sequence_hash?: string; 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' ]; export const ZKEVM_L2_TX_STATUSES = [ 'Confirmed by Sequencer', 'L1 Confirmed' ];
...@@ -104,6 +110,15 @@ export interface TransactionsResponsePending { ...@@ -104,6 +110,15 @@ export interface TransactionsResponsePending {
} | null; } | null;
} }
export interface TransactionsResponseWithBlobs {
items: Array<Transaction>;
next_page_params: {
block_number: number;
index: number;
items_count: number;
} | null;
}
export interface TransactionsResponseWatchlist { export interface TransactionsResponseWatchlist {
items: Array<Transaction>; items: Array<Transaction>;
next_page_params: { next_page_params: {
...@@ -119,7 +134,8 @@ export type TransactionType = 'rootstock_remasc' | ...@@ -119,7 +134,8 @@ export type TransactionType = 'rootstock_remasc' |
'contract_creation' | 'contract_creation' |
'contract_call' | 'contract_call' |
'token_creation' | 'token_creation' |
'coin_transfer' 'coin_transfer' |
'blob_transaction'
export type TxsResponse = TransactionsResponseValidated | TransactionsResponsePending | BlockTransactionsResponse; export type TxsResponse = TransactionsResponseValidated | TransactionsResponsePending | BlockTransactionsResponse;
......
...@@ -4,6 +4,10 @@ export type TTxsFilters = { ...@@ -4,6 +4,10 @@ export type TTxsFilters = {
method?: Array<MethodFilter>; 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'; export type MethodFilter = 'approve' | 'transfer' | 'multicall' | 'mint' | 'commit';
...@@ -49,8 +49,10 @@ export type UserOp = { ...@@ -49,8 +49,10 @@ export type UserOp = {
user_logs_start_index: number; user_logs_start_index: number;
user_logs_count: number; user_logs_count: number;
raw: { raw: {
account_gas_limits?: string;
call_data: string; call_data: string;
call_gas_limit: string; call_gas_limit: string;
gas_fees?: string;
init_code: string; init_code: string;
max_fee_per_gas: string; max_fee_per_gas: string;
max_priority_fee_per_gas: string; max_priority_fee_per_gas: string;
......
...@@ -5,7 +5,7 @@ export type ZkEvmL2TxnBatchesItem = { ...@@ -5,7 +5,7 @@ export type ZkEvmL2TxnBatchesItem = {
verify_tx_hash: string | null; verify_tx_hash: string | null;
sequence_tx_hash: string | null; sequence_tx_hash: string | null;
status: string; status: string;
timestamp: string; timestamp: string | null;
tx_count: number; tx_count: number;
} }
...@@ -26,7 +26,7 @@ export type ZkEvmL2TxnBatch = { ...@@ -26,7 +26,7 @@ export type ZkEvmL2TxnBatch = {
sequence_tx_hash: string; sequence_tx_hash: string;
state_root: string; state_root: string;
status: typeof ZKEVM_L2_TX_BATCH_STATUSES[number]; status: typeof ZKEVM_L2_TX_BATCH_STATUSES[number];
timestamp: string; timestamp: string | null;
transactions: Array<string>; transactions: Array<string>;
verify_tx_hash: string; verify_tx_hash: string;
} }
......
import type { ArrayElement } from 'types/utils'; 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 type AdBannerProviders = ArrayElement<typeof SUPPORTED_AD_BANNER_PROVIDERS>;
export const SUPPORTED_AD_TEXT_PROVIDERS = [ 'coinzilla', 'none' ] as const; export const SUPPORTED_AD_TEXT_PROVIDERS = [ 'coinzilla', 'none' ] as const;
......
...@@ -16,3 +16,9 @@ export const TX_ADDITIONAL_FIELDS_IDS = [ ...@@ -16,3 +16,9 @@ export const TX_ADDITIONAL_FIELDS_IDS = [
] as const; ] as const;
export type TxAdditionalFieldsId = ArrayElement<typeof TX_ADDITIONAL_FIELDS_IDS>; 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'; ...@@ -6,6 +6,7 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import AddressCounterItem from 'ui/address/details/AddressCounterItem'; import AddressCounterItem from 'ui/address/details/AddressCounterItem';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning'; import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem'; import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
...@@ -59,16 +60,17 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => { ...@@ -59,16 +60,17 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
has_validated_blocks: false, has_validated_blocks: false,
}), [ addressHash ]); }), [ addressHash ]);
// error handling (except 404 codes)
if (addressQuery.isError) {
if (isCustomAppError(addressQuery.error)) {
const is404Error = addressQuery.isError && 'status' in addressQuery.error && addressQuery.error.status === 404; 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 (!is404Error) {
if (addressQuery.isError && is422Error) {
throwOnResourceLoadError(addressQuery); throwOnResourceLoadError(addressQuery);
} }
} else {
if (addressQuery.isError && !is404Error) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
}
const data = addressQuery.isError ? error404Data : addressQuery.data; const data = addressQuery.isError ? error404Data : addressQuery.data;
......
...@@ -20,6 +20,7 @@ import Pagination from 'ui/shared/pagination/Pagination'; ...@@ -20,6 +20,7 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue'; import getSortParamsFromValue from 'ui/shared/sort/getSortParamsFromValue';
import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery'; import getSortValueFromQuery from 'ui/shared/sort/getSortValueFromQuery';
import { sortTxsFromSocket } from 'ui/txs/sortTxs';
import TxsWithAPISorting from 'ui/txs/TxsWithAPISorting'; import TxsWithAPISorting from 'ui/txs/TxsWithAPISorting';
import { SORT_OPTIONS } from 'ui/txs/useTxsSort'; import { SORT_OPTIONS } from 'ui/txs/useTxsSort';
...@@ -85,7 +86,7 @@ const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => { ...@@ -85,7 +86,7 @@ const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => {
addressTxsQuery.onFilterChange({ filter: newVal }); addressTxsQuery.onFilterChange({ filter: newVal });
}, [ addressTxsQuery ]); }, [ addressTxsQuery ]);
const handleNewSocketMessage: SocketMessage.AddressTxs['handler'] = (payload) => { const handleNewSocketMessage: SocketMessage.AddressTxs['handler'] = React.useCallback((payload) => {
setSocketAlert(''); setSocketAlert('');
queryClient.setQueryData( queryClient.setQueryData(
...@@ -123,10 +124,10 @@ const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => { ...@@ -123,10 +124,10 @@ const AddressTxs = ({ scrollRef, overloadCount = OVERLOAD_COUNT }: Props) => {
items: [ items: [
...newItems, ...newItems,
...prevData.items, ...prevData.items,
], ].sort(sortTxsFromSocket(sort)),
}; };
}); });
}; }, [ currentAddress, filterValue, overloadCount, queryClient, sort ]);
const handleSocketClose = React.useCallback(() => { const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please refresh the page to load new transactions.'); 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'; 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 useApiQuery from 'lib/api/useApiQuery';
import { SOLIDITYSCAN_REPORT } from 'stubs/contract'; import { SOLIDITYSCAN_REPORT } from 'stubs/contract';
import LinkExternal from 'ui/shared/LinkExternal'; import LinkExternal from 'ui/shared/LinkExternal';
...@@ -39,7 +43,11 @@ const SolidityscanReport = ({ className, hash }: Props) => { ...@@ -39,7 +43,11 @@ const SolidityscanReport = ({ className, hash }: Props) => {
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
popoverContent={ ( 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 }/> <SolidityscanReportScore score={ score }/>
{ vulnerabilities && vulnerabilitiesCount > 0 && ( { vulnerabilities && vulnerabilitiesCount > 0 && (
<Box mb={ 5 }> <Box mb={ 5 }>
......
...@@ -163,6 +163,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -163,6 +163,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
libraries={ primaryContractQuery.data?.external_libraries ?? undefined } libraries={ primaryContractQuery.data?.external_libraries ?? undefined }
language={ primaryContractQuery.data?.language ?? undefined } language={ primaryContractQuery.data?.language ?? undefined }
mainFile={ primaryEditorData[0]?.file_path } mainFile={ primaryEditorData[0]?.file_path }
contractName={ primaryContractQuery.data?.name || undefined }
/> />
</Box> </Box>
{ secondaryEditorData && ( { secondaryEditorData && (
...@@ -173,6 +174,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -173,6 +174,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
libraries={ secondaryContractQuery.data?.external_libraries ?? undefined } libraries={ secondaryContractQuery.data?.external_libraries ?? undefined }
language={ secondaryContractQuery.data?.language ?? undefined } language={ secondaryContractQuery.data?.language ?? undefined }
mainFile={ secondaryEditorData?.[0]?.file_path } mainFile={ secondaryEditorData?.[0]?.file_path }
contractName={ secondaryContractQuery.data?.name || undefined }
/> />
</Box> </Box>
) } ) }
......
...@@ -10,6 +10,7 @@ import ClearButton from 'ui/shared/ClearButton'; ...@@ -10,6 +10,7 @@ import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel'; import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton'; import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useArgTypeMatchInt from './useArgTypeMatchInt'; import useArgTypeMatchInt from './useArgTypeMatchInt';
import useFormatFieldValue from './useFormatFieldValue';
import useValidateField from './useValidateField'; import useValidateField from './useValidateField';
interface Props { interface Props {
...@@ -29,15 +30,22 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -29,15 +30,22 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type }); const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type });
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt }); const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
const { control, setValue, getValues } = useFormContext(); 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 inputBgColor = useColorModeValue('white', 'black');
const nativeCoinRowBgColor = useColorModeValue('gray.100', 'gray.700'); const nativeCoinRowBgColor = useColorModeValue('gray.100', 'gray.700');
const hasMultiplyButton = argTypeMatchInt && Number(argTypeMatchInt.power) >= 64; 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(() => { const handleClear = React.useCallback(() => {
setValue(name, ''); setValue(name, '');
ref.current?.focus(); ref.current?.focus();
...@@ -46,9 +54,9 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -46,9 +54,9 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const handleMultiplyButtonClick = React.useCallback((power: number) => { const handleMultiplyButtonClick = React.useCallback((power: number) => {
const zeroes = Array(power).fill('0').join(''); const zeroes = Array(power).fill('0').join('');
const value = getValues(name); const value = getValues(name);
const newValue = value ? value + zeroes : '1' + zeroes; const newValue = format(value ? value + zeroes : '1' + zeroes);
setValue(name, newValue); setValue(name, newValue);
}, [ getValues, name, setValue ]); }, [ format, getValues, name, setValue ]);
const error = fieldState.error; const error = fieldState.error;
...@@ -76,6 +84,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -76,6 +84,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
allowNegative: !argTypeMatchInt.isUnsigned, allowNegative: !argTypeMatchInt.isUnsigned,
} : {}) } } : {}) }
ref={ ref } ref={ ref }
onChange={ handleChange }
required={ !isOptional } required={ !isOptional }
isInvalid={ Boolean(error) } isInvalid={ Boolean(error) }
placeholder={ data.type } placeholder={ data.type }
...@@ -84,7 +93,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -84,7 +93,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
paddingRight={ hasMultiplyButton ? '120px' : '40px' } paddingRight={ hasMultiplyButton ? '120px' : '40px' }
/> />
<InputRightElement w="auto" right={ 1 }> <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 }/> } { hasMultiplyButton && <ContractMethodMultiplyButton onClick={ handleMultiplyButtonClick } isDisabled={ isDisabled }/> }
</InputRightElement> </InputRightElement>
</InputGroup> </InputGroup>
......
import { Box, Button, Flex, chakra } from '@chakra-ui/react'; import { Box, Button, Flex, chakra } from '@chakra-ui/react';
import _mapValues from 'lodash/mapValues';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
...@@ -35,7 +36,11 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -35,7 +36,11 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
}); });
const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => { 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); setResult(undefined);
setLoading(true); setLoading(true);
......
...@@ -20,11 +20,11 @@ const ContractMethodFormOutputs = ({ data }: Props) => { ...@@ -20,11 +20,11 @@ const ContractMethodFormOutputs = ({ data }: Props) => {
<p> <p>
{ data.map(({ type, name }, index) => { { data.map(({ type, name }, index) => {
return ( return (
<> <React.Fragment key={ index }>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span> <chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span> <span>{ name ? `(${ type })` : type }</span>
{ index < data.length - 1 && <span>, </span> } { index < data.length - 1 && <span>, </span> }
</> </React.Fragment>
); );
}) } }) }
</p> </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'; ...@@ -4,7 +4,7 @@ import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract'; import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt'; import type { MatchInt } from './useArgTypeMatchInt';
import { BYTES_REGEXP, formatBooleanValue } from './utils'; import { BYTES_REGEXP } from './utils';
interface Params { interface Params {
argType: SmartContractMethodArgType; argType: SmartContractMethodArgType;
...@@ -18,13 +18,15 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt ...@@ -18,13 +18,15 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt
return argType.match(BYTES_REGEXP); return argType.match(BYTES_REGEXP);
}, [ argType ]); }, [ argType ]);
return React.useCallback((value: string | undefined) => { // some values are formatted before they are sent to the validator
if (!value) { // see ./useFormatFieldValue.tsx hook
return React.useCallback((value: string | number | boolean | undefined) => {
if (value === undefined || value === '') {
return isOptional ? true : 'Field is required'; return isOptional ? true : 'Field is required';
} }
if (argType === 'address') { if (argType === 'address') {
if (!isAddress(value)) { if (typeof value !== 'string' || !isAddress(value)) {
return 'Invalid address format'; return 'Invalid address format';
} }
...@@ -39,13 +41,11 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt ...@@ -39,13 +41,11 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt
} }
if (argTypeMatchInt) { if (argTypeMatchInt) {
const formattedValue = Number(value.replace(/\s/g, '')); if (typeof value !== 'number' || Object.is(value, NaN)) {
if (Object.is(formattedValue, NaN)) {
return 'Invalid integer format'; 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 lowerBoundary = argTypeMatchInt.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(argTypeMatchInt.power) - 1 }`;
const upperBoundary = argTypeMatchInt.isUnsigned ? `2 ^ ${ argTypeMatchInt.power } - 1` : `2 ^ ${ Number(argTypeMatchInt.power) - 1 } - 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`; return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`;
...@@ -55,9 +55,8 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt ...@@ -55,9 +55,8 @@ export default function useValidateField({ isOptional, argType, argTypeMatchInt
} }
if (argType === 'bool') { if (argType === 'bool') {
const formattedValue = formatBooleanValue(value); if (typeof value !== 'boolean') {
if (formattedValue === undefined) { return 'Invalid boolean format. Allowed values: true, false';
return 'Invalid boolean format. Allowed values: 0, 1, true, false';
} }
} }
......
...@@ -2,7 +2,7 @@ import _set from 'lodash/set'; ...@@ -2,7 +2,7 @@ import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract'; 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; export const INT_REGEXP = /^(u)?int(\d+)?$/i;
...@@ -17,25 +17,6 @@ export const getIntBoundaries = (power: number, isUnsigned: boolean) => { ...@@ -17,25 +17,6 @@ export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
return [ min, max ]; 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) { export function transformFormDataToMethodArgs(formData: ContractMethodFormFields) {
const result: Array<unknown> = []; const result: Array<unknown> = [];
......
...@@ -148,7 +148,7 @@ base('long values', async({ mount, page }) => { ...@@ -148,7 +148,7 @@ base('long values', async({ mount, page }) => {
}), { times: 1 }); }), { times: 1 });
await page.route(TOKENS_ERC20_API_URL, async(route) => route.fulfill({ await page.route(TOKENS_ERC20_API_URL, async(route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify({ items: [ tokensMock.erc20LongSymbol ] }), body: JSON.stringify({ items: [ tokensMock.erc20LongSymbol, tokensMock.erc20BigAmount ] }),
}), { times: 1 }); }), { times: 1 });
await page.route(TOKENS_ERC721_API_URL, async(route) => route.fulfill({ await page.route(TOKENS_ERC721_API_URL, async(route) => route.fulfill({
status: 200, status: 200,
......
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import type { FormattedData } from './types'; import type { FormattedData } from './types';
import { space } from 'lib/html-entities';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
...@@ -42,7 +43,16 @@ const TokenSelectButton = ({ isOpen, isLoading, onClick, data }: Props, ref: Rea ...@@ -42,7 +43,16 @@ const TokenSelectButton = ({ isOpen, isLoading, onClick, data }: Props, ref: Rea
> >
<IconSvg name="tokens" boxSize={ 4 } mr={ 2 }/> <IconSvg name="tokens" boxSize={ 4 } mr={ 2 }/>
<Text fontWeight={ 600 }>{ prefix }{ num }</Text> <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 }/> <IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 3 }/>
</Button> </Button>
{ isLoading && !isOpen && <Skeleton h="100%" w="100%" position="absolute" top={ 0 } left={ 0 } bgColor={ skeletonBgColor } borderRadius="base"/> } { 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 React from 'react';
import type { FormattedData } from './types'; import type { FormattedData } from './types';
...@@ -15,8 +15,6 @@ interface Props { ...@@ -15,8 +15,6 @@ interface Props {
const TokenSelectDesktop = ({ data, isLoading }: Props) => { const TokenSelectDesktop = ({ data, isLoading }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure(); const { isOpen, onToggle, onClose } = useDisclosure();
const bgColor = useColorModeValue('white', 'gray.900');
const result = useTokenSelect(data); const result = useTokenSelect(data);
return ( return (
...@@ -25,7 +23,7 @@ const TokenSelectDesktop = ({ data, isLoading }: Props) => { ...@@ -25,7 +23,7 @@ const TokenSelectDesktop = ({ data, isLoading }: Props) => {
<TokenSelectButton isOpen={ isOpen } onClick={ onToggle } data={ result.data } isLoading={ isLoading }/> <TokenSelectButton isOpen={ isOpen } onClick={ onToggle } data={ result.data } isLoading={ isLoading }/>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w="355px" maxH="450px" overflowY="scroll"> <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 }/> <TokenSelectMenu { ...result }/>
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
......
import { chakra, Flex, Text, useColorModeValue } from '@chakra-ui/react'; import { chakra, Flex, useColorModeValue } from '@chakra-ui/react';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
...@@ -62,7 +62,7 @@ const TokenSelectItem = ({ data }: Props) => { ...@@ -62,7 +62,7 @@ const TokenSelectItem = ({ data }: Props) => {
_hover={{ _hover={{
bgColor: useColorModeValue('blue.50', 'gray.800'), bgColor: useColorModeValue('blue.50', 'gray.800'),
}} }}
color="initial" color="unset"
fontSize="sm" fontSize="sm"
href={ url } href={ url }
> >
...@@ -73,8 +73,11 @@ const TokenSelectItem = ({ data }: Props) => { ...@@ -73,8 +73,11 @@ const TokenSelectItem = ({ data }: Props) => {
noCopy noCopy
noLink noLink
fontWeight={ 700 } 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>
<Flex alignItems="center" justifyContent="space-between" w="100%" whiteSpace="nowrap"> <Flex alignItems="center" justifyContent="space-between" w="100%" whiteSpace="nowrap">
{ secondRow } { secondRow }
......
...@@ -46,17 +46,17 @@ const ERC20TokensListItem = ({ token, value, isLoading }: Props) => { ...@@ -46,17 +46,17 @@ const ERC20TokensListItem = ({ token, value, isLoading }: Props) => {
</Skeleton> </Skeleton>
</HStack> </HStack>
) } ) }
<HStack spacing={ 3 }> <HStack spacing={ 3 } alignItems="baseline">
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Quantity</Skeleton> <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> <span>{ tokenQuantity }</span>
</Skeleton> </Skeleton>
</HStack> </HStack>
{ tokenValue !== undefined && ( { tokenValue !== undefined && (
<HStack spacing={ 3 }> <HStack spacing={ 3 } alignItems="baseline">
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Value</Skeleton> <Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Value</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary"> <Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" whiteSpace="pre-wrap" wordBreak="break-word">
<span>{ tokenValue }</span> <span>${ tokenValue }</span>
</Skeleton> </Skeleton>
</HStack> </HStack>
) } ) }
......
...@@ -26,7 +26,8 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P ...@@ -26,7 +26,8 @@ const NFTItem = ({ token, value, isLoading, withTokenLink, ...tokenInstance }: P
<Link href={ isLoading ? undefined : tokenInstanceLink }> <Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia <NftMedia
mb="18px" mb="18px"
url={ tokenInstance?.animation_url || tokenInstance?.image_url || null } animationUrl={ tokenInstance?.animation_url ?? null }
imageUrl={ tokenInstance?.image_url ?? null }
isLoading={ isLoading } isLoading={ isLoading }
/> />
</Link> </Link>
......
...@@ -9,10 +9,10 @@ const TokenBalancesItem = ({ name, value, isLoading }: {name: string; value: str ...@@ -9,10 +9,10 @@ const TokenBalancesItem = ({ name, value, isLoading }: {name: string; value: str
return ( return (
<Flex p={ 5 } bgColor={ bgColor } borderRadius="16px" alignItems="center"> <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> <Box>
<Text variant="secondary" fontSize="xs">{ name }</Text> <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> </Box>
</Flex> </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 }) => { ...@@ -51,6 +51,24 @@ test('genesis block', async({ mount, page }) => {
await expect(component).toHaveScreenshot(); 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({ const customFieldsTest = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any, context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any,
......
...@@ -28,6 +28,7 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet'; ...@@ -28,6 +28,7 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet';
import TextSeparator from 'ui/shared/TextSeparator'; import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
import BlockDetailsBlobInfo from './details/BlockDetailsBlobInfo';
import type { BlockQuery } from './useBlockQuery'; import type { BlockQuery } from './useBlockQuery';
interface Props { interface Props {
...@@ -114,6 +115,31 @@ const BlockDetails = ({ query }: Props) => { ...@@ -114,6 +115,31 @@ const BlockDetails = ({ query }: Props) => {
return config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by'; 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 = (() => { const blockTypeLabel = (() => {
switch (data.type) { switch (data.type) {
case 'reorg': case 'reorg':
...@@ -172,9 +198,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -172,9 +198,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
<LinkInternal href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'txs' } }) }> { txsNum }
{ data.tx_count } transaction{ data.tx_count === 1 ? '' : 's' }
</LinkInternal>
</Skeleton> </Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
{ config.features.beaconChain.isEnabled && Boolean(data.withdrawals_count) && ( { config.features.beaconChain.isEnabled && Boolean(data.withdrawals_count) && (
...@@ -364,6 +388,8 @@ const BlockDetails = ({ query }: Props) => { ...@@ -364,6 +388,8 @@ const BlockDetails = ({ query }: Props) => {
<> <>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/> <GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
{ !isPlaceholderData && <BlockDetailsBlobInfo data={ data }/> }
{ data.bitcoin_merged_mining_header && ( { data.bitcoin_merged_mining_header && (
<DetailsInfoItem <DetailsInfoItem
title="Bitcoin merged mining header" 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 { ...@@ -33,7 +33,7 @@ interface Params {
tab: string; 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 [ isRefetchEnabled, setRefetchEnabled ] = React.useState(false);
const apiQuery = useQueryWithPages({ const apiQuery = useQueryWithPages({
......
...@@ -7,18 +7,20 @@ import type { ChainIndicatorId } from 'types/homepage'; ...@@ -7,18 +7,20 @@ import type { ChainIndicatorId } from 'types/homepage';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import IconSvg from 'ui/shared/IconSvg';
interface Props { interface Props {
id: ChainIndicatorId; id: ChainIndicatorId;
title: string; title: string;
value: (stats: HomeStats) => string; value: (stats: HomeStats) => string;
valueDiff?: (stats?: HomeStats) => number | null | undefined;
icon: React.ReactNode; icon: React.ReactNode;
isSelected: boolean; isSelected: boolean;
onClick: (id: ChainIndicatorId) => void; onClick: (id: ChainIndicatorId) => void;
stats: UseQueryResult<HomeStats, ResourceError<unknown>>; 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 isMobile = useIsMobile();
const activeBgColorDesktop = useColorModeValue('white', 'gray.900'); const activeBgColorDesktop = useColorModeValue('white', 'gray.900');
...@@ -53,6 +55,25 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats ...@@ -53,6 +55,25 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats
return <Text variant="secondary" fontWeight={ 600 }>{ value(stats.data) }</Text>; 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 ( return (
<Flex <Flex
alignItems="center" alignItems="center"
...@@ -73,7 +94,10 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats ...@@ -73,7 +94,10 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats
{ icon } { icon }
<Box> <Box>
<Text fontFamily="heading" fontWeight={ 500 }>{ title }</Text> <Text fontFamily="heading" fontWeight={ 500 }>{ title }</Text>
<Flex alignItems="center">
{ valueContent } { valueContent }
{ valueDiffContent }
</Flex>
</Box> </Box>
</Flex> </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 React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS } from 'stubs/stats';
import Hint from 'ui/shared/Hint'; import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg';
import ChainIndicatorChartContainer from './ChainIndicatorChartContainer'; import ChainIndicatorChartContainer from './ChainIndicatorChartContainer';
import ChainIndicatorItem from './ChainIndicatorItem'; import ChainIndicatorItem from './ChainIndicatorItem';
...@@ -56,19 +57,39 @@ const ChainIndicators = () => { ...@@ -56,19 +57,39 @@ const ChainIndicators = () => {
} }
return ( 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) } { indicator?.value(statsQueryResult.data) }
</Text> </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 ( return (
<Flex <Flex
p={{ base: 0, lg: 8 }} p={{ base: 0, lg: 8 }}
borderRadius={{ base: 'none', lg: 'lg' }} borderRadius={{ base: 'none', lg: 'lg' }}
boxShadow={{ base: 'none', lg: 'xl' }} boxShadow={{ base: 'none', lg: 'xl' }}
bgColor={{ base: bgColorMobile, lg: bgColorDesktop }} bgColor={{ base: bgColorMobile, lg: bgColorDesktop }}
columnGap={ 12 } columnGap={ 6 }
rowGap={ 0 } rowGap={ 0 }
flexDir={{ base: 'column', lg: 'row' }} flexDir={{ base: 'column', lg: 'row' }}
w="100%" w="100%"
...@@ -80,7 +101,10 @@ const ChainIndicators = () => { ...@@ -80,7 +101,10 @@ const ChainIndicators = () => {
<Text fontWeight={ 500 } fontFamily="heading" fontSize="lg">{ indicator?.title }</Text> <Text fontWeight={ 500 } fontFamily="heading" fontSize="lg">{ indicator?.title }</Text>
{ indicator?.hint && <Hint label={ indicator.hint } ml={ 1 }/> } { indicator?.hint && <Hint label={ indicator.hint } ml={ 1 }/> }
</Flex> </Flex>
<Box mb={ 4 }>
{ valueTitle } { valueTitle }
{ valueDiff }
</Box>
<ChainIndicatorChartContainer { ...queryResult }/> <ChainIndicatorChartContainer { ...queryResult }/>
</Flex> </Flex>
{ indicators.length > 1 && ( { indicators.length > 1 && (
......
...@@ -10,6 +10,7 @@ export interface TChainIndicator<R extends ChartsResources> { ...@@ -10,6 +10,7 @@ export interface TChainIndicator<R extends ChartsResources> {
id: ChainIndicatorId; id: ChainIndicatorId;
title: string; title: string;
value: (stats: HomeStats) => string; value: (stats: HomeStats) => string;
valueDiff?: (stats?: HomeStats) => number | null | undefined;
icon: React.ReactNode; icon: React.ReactNode;
hint?: string; hint?: string;
api: { api: {
......
...@@ -54,6 +54,7 @@ const coinPriceIndicator: TChainIndicator<'stats_charts_market'> = { ...@@ -54,6 +54,7 @@ const coinPriceIndicator: TChainIndicator<'stats_charts_market'> = {
value: (stats) => stats.coin_price === null ? value: (stats) => stats.coin_price === null ?
'$N/A' : '$N/A' :
'$' + Number(stats.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }), '$' + 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 }/>, icon: <TokenEntity.Icon token={ nativeTokenData } boxSize={ 6 } marginRight={ 0 }/>,
hint: `${ config.chain.governanceToken.symbol || config.chain.currency.symbol } token daily price in USD.`, hint: `${ config.chain.governanceToken.symbol || config.chain.currency.symbol } token daily price in USD.`,
api: { api: {
......
...@@ -19,7 +19,7 @@ const MarketplaceAppAlert = ({ internalWallet, isWalletConnected }: Props) => { ...@@ -19,7 +19,7 @@ const MarketplaceAppAlert = ({ internalWallet, isWalletConnected }: Props) => {
icon = 'integration/full'; icon = 'integration/full';
text = 'Your wallet is connected with Blockscout'; text = 'Your wallet is connected with Blockscout';
status = 'success'; status = 'success';
} else if (isWalletConnected) { } else if (!internalWallet) {
icon = 'integration/partial'; icon = 'integration/partial';
text = 'Connect your wallet in the app below'; text = 'Connect your wallet in the app below';
} }
......
...@@ -52,7 +52,7 @@ function sortApps(apps: Array<MarketplaceAppWithSecurityReport>, favoriteApps: A ...@@ -52,7 +52,7 @@ function sortApps(apps: Array<MarketplaceAppWithSecurityReport>, favoriteApps: A
export default function useMarketplaceApps( export default function useMarketplaceApps(
filter: string, filter: string,
selectedCategoryId: string = MarketplaceCategory.ALL, selectedCategoryId: string = MarketplaceCategory.ALL,
favoriteApps: Array<string> = [], favoriteApps: Array<string> | undefined = undefined,
isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types
) { ) {
const fetch = useFetch(); const fetch = useFetch();
...@@ -61,13 +61,16 @@ export default function useMarketplaceApps( ...@@ -61,13 +61,16 @@ export default function useMarketplaceApps(
const { data: securityReports, isPlaceholderData: isSecurityReportsPlaceholderData } = useSecurityReports(); const { data: securityReports, isPlaceholderData: isSecurityReportsPlaceholderData } = useSecurityReports();
// Update favorite apps only when selectedCategoryId changes to avoid sortApps to be called on each favorite app click // 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(() => { React.useEffect(() => {
lastFavoriteAppsRef.current = favoriteApps; if (isFavoriteAppsLoaded) {
setSnapshotFavoriteApps(favoriteApps);
}
}, [ selectedCategoryId, isFavoriteAppsLoaded ]); // eslint-disable-line react-hooks/exhaustive-deps }, [ selectedCategoryId, isFavoriteAppsLoaded ]); // eslint-disable-line react-hooks/exhaustive-deps
const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppWithSecurityReport>>({ const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppWithSecurityReport>>({
queryKey: [ 'marketplace-dapps' ], queryKey: [ 'marketplace-dapps', snapshotFavoriteApps, favoriteApps ],
queryFn: async() => { queryFn: async() => {
if (!feature.isEnabled) { if (!feature.isEnabled) {
return []; return [];
...@@ -77,10 +80,10 @@ export default function useMarketplaceApps( ...@@ -77,10 +80,10 @@ export default function useMarketplaceApps(
return apiFetch('marketplace_dapps', { pathParams: { chainId: config.chain.id } }); 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, placeholderData: feature.isEnabled ? Array(9).fill(MARKETPLACE_APP) : undefined,
staleTime: Infinity, staleTime: Infinity,
enabled: feature.isEnabled, enabled: feature.isEnabled && (!favoriteApps || Boolean(snapshotFavoriteApps)),
}); });
const appsWithSecurityReports = React.useMemo(() => const appsWithSecurityReports = React.useMemo(() =>
...@@ -88,7 +91,7 @@ export default function useMarketplaceApps( ...@@ -88,7 +91,7 @@ export default function useMarketplaceApps(
[ data, securityReports ]); [ data, securityReports ]);
const displayedApps = React.useMemo(() => { 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 ]); }, [ selectedCategoryId, appsWithSecurityReports, filter, favoriteApps ]);
return React.useMemo(() => ({ return React.useMemo(() => ({
......
...@@ -236,6 +236,7 @@ const AddressPageContent = () => { ...@@ -236,6 +236,7 @@ const AddressPageContent = () => {
secondRow={ titleSecondRow } secondRow={ titleSecondRow }
isLoading={ isLoading } isLoading={ isLoading }
/> />
{ config.features.metasuites.isEnabled && <Box display="none" id="meta-suites__address" data-ready={ !isLoading }/> }
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/> <AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
{ /* should stay before tabs to scroll up with pagination */ } { /* should stay before tabs to scroll up with pagination */ }
<Box ref={ tabsScrollRef }></Box> <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'; ...@@ -13,8 +13,9 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import BlockDetails from 'ui/block/BlockDetails'; import BlockDetails from 'ui/block/BlockDetails';
import BlockWithdrawals from 'ui/block/BlockWithdrawals'; import BlockWithdrawals from 'ui/block/BlockWithdrawals';
import useBlockBlobTxsQuery from 'ui/block/useBlockBlobTxsQuery';
import useBlockQuery from 'ui/block/useBlockQuery'; 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 useBlockWithdrawalsQuery from 'ui/block/useBlockWithdrawalsQuery';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning'; import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
...@@ -40,8 +41,9 @@ const BlockPageContent = () => { ...@@ -40,8 +41,9 @@ const BlockPageContent = () => {
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
const blockQuery = useBlockQuery({ heightOrHash }); const blockQuery = useBlockQuery({ heightOrHash });
const blockTxsQuery = useBlockTxQuery({ heightOrHash, blockQuery, tab }); const blockTxsQuery = useBlockTxsQuery({ heightOrHash, blockQuery, tab });
const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab }); const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab });
const blockBlobTxsQuery = useBlockBlobTxsQuery({ heightOrHash, blockQuery, tab });
const tabs: Array<RoutedTab> = React.useMemo(() => ([ const tabs: Array<RoutedTab> = React.useMemo(() => ([
{ {
...@@ -64,6 +66,14 @@ const BlockPageContent = () => { ...@@ -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) ? config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.withdrawals_count) ?
{ {
id: 'withdrawals', id: 'withdrawals',
...@@ -75,7 +85,7 @@ const BlockPageContent = () => { ...@@ -75,7 +85,7 @@ const BlockPageContent = () => {
</> </>
), ),
} : null, } : null,
].filter(Boolean)), [ blockQuery, blockTxsQuery, blockWithdrawalsQuery ]); ].filter(Boolean)), [ blockBlobTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery ]);
const hasPagination = !isMobile && ( const hasPagination = !isMobile && (
(tab === 'txs' && blockTxsQuery.pagination.isVisible) || (tab === 'txs' && blockTxsQuery.pagination.isVisible) ||
......
...@@ -39,7 +39,7 @@ const OptimisticL2Withdrawals = () => { ...@@ -39,7 +39,7 @@ const OptimisticL2Withdrawals = () => {
<> <>
<Show below="lg" ssr={ false }>{ data.items.map(((item, index) => ( <Show below="lg" ssr={ false }>{ data.items.map(((item, index) => (
<OptimisticL2WithdrawalsListItem <OptimisticL2WithdrawalsListItem
key={ item.l2_tx_hash + (isPlaceholderData ? index : '') } key={ String(item.msg_nonce_version) + item.msg_nonce + (isPlaceholderData ? index : '') }
item={ item } item={ item }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
/> />
......
...@@ -158,6 +158,31 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -158,6 +158,31 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(component.locator('main')).toHaveScreenshot(); 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({ const testWithUserOps = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any, context: contextWithEnvs(configs.featureEnvs.userOps) as any,
......
...@@ -58,6 +58,11 @@ const SearchResultsPageContent = () => { ...@@ -58,6 +58,11 @@ const SearchResultsPageContent = () => {
router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } }); router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return; 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'; ...@@ -9,11 +9,13 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { publicClient } from 'lib/web3/client'; import { publicClient } from 'lib/web3/client';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import EntityTags from 'ui/shared/EntityTags'; import EntityTags from 'ui/shared/EntityTags';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery'; import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxBlobs from 'ui/tx/TxBlobs';
import TxDetails from 'ui/tx/TxDetails'; import TxDetails from 'ui/tx/TxDetails';
import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded'; import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded';
import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped'; import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped';
...@@ -55,6 +57,9 @@ const TransactionPageContent = () => { ...@@ -55,6 +57,9 @@ const TransactionPageContent = () => {
{ id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } : { id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } :
undefined, undefined,
{ id: 'internal', title: 'Internal txns', component: <TxInternals txQuery={ txQuery }/> }, { 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: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery }/> },
{ id: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> }, { id: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> }, { id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> },
...@@ -99,7 +104,7 @@ const TransactionPageContent = () => { ...@@ -99,7 +104,7 @@ const TransactionPageContent = () => {
})(); })();
if (isError && !showDegradedView) { if (isError && !showDegradedView) {
if (error?.status === 422 || error?.status === 404) { if (isCustomAppError(error)) {
throwOnResourceLoadError({ resource: 'tx', error, isError: true }); throwOnResourceLoadError({ resource: 'tx', error, isError: true });
} }
} }
......
...@@ -7,6 +7,7 @@ import config from 'configs/app'; ...@@ -7,6 +7,7 @@ import config from 'configs/app';
import useHasAccount from 'lib/hooks/useHasAccount'; import useHasAccount from 'lib/hooks/useHasAccount';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket'; import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx'; import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -26,11 +27,19 @@ const Transactions = () => { ...@@ -26,11 +27,19 @@ const Transactions = () => {
const verifiedTitle = config.chain.verificationType === 'validation' ? 'Validated' : 'Mined'; const verifiedTitle = config.chain.verificationType === 'validation' ? 'Validated' : 'Mined';
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const txsQuery = useQueryWithPages({ const tab = getQueryParamString(router.query.tab);
resourceName: router.query.tab === 'pending' ? 'txs_pending' : 'txs_validated',
filters: { filter: router.query.tab === 'pending' ? 'pending' : 'validated' }, 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: { 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: { placeholderData: generateListStub<'txs_validated'>(TX, 50, { next_page_params: {
block_number: 9005713, block_number: 9005713,
index: 5, index: 5,
...@@ -40,10 +49,36 @@ const Transactions = () => { ...@@ -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({ const txsWatchlistQuery = useQueryWithPages({
resourceName: 'txs_watchlist', resourceName: 'txs_watchlist',
options: { options: {
enabled: router.query.tab === 'watchlist', enabled: tab === 'watchlist',
placeholderData: generateListStub<'txs_watchlist'>(TX, 50, { next_page_params: { placeholderData: generateListStub<'txs_watchlist'>(TX, 50, { next_page_params: {
block_number: 9005713, block_number: 9005713,
index: 5, index: 5,
...@@ -61,15 +96,32 @@ const Transactions = () => { ...@@ -61,15 +96,32 @@ const Transactions = () => {
id: 'validated', id: 'validated',
title: verifiedTitle, title: verifiedTitle,
component: 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', id: 'pending',
title: 'Pending', title: 'Pending',
component: ( component: (
<TxsWithFrontendSorting <TxsWithFrontendSorting
query={ txsQuery } query={ txsPendingQuery }
showBlockInfo={ false } 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 } socketInfoNum={ num }
socketInfoAlert={ socketAlert } socketInfoAlert={ socketAlert }
/> />
...@@ -82,7 +134,14 @@ const Transactions = () => { ...@@ -82,7 +134,14 @@ const Transactions = () => {
} : undefined, } : undefined,
].filter(Boolean); ].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 ( return (
<> <>
......
...@@ -13,7 +13,6 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; ...@@ -13,7 +13,6 @@ import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { USER_OP } from 'stubs/userOps'; import { USER_OP } from 'stubs/userOps';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
...@@ -23,6 +22,7 @@ import TxTokenTransfer from 'ui/tx/TxTokenTransfer'; ...@@ -23,6 +22,7 @@ import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import useTxQuery from 'ui/tx/useTxQuery'; import useTxQuery from 'ui/tx/useTxQuery';
import UserOpDetails from 'ui/userOp/UserOpDetails'; import UserOpDetails from 'ui/userOp/UserOpDetails';
import UserOpRaw from 'ui/userOp/UserOpRaw'; import UserOpRaw from 'ui/userOp/UserOpRaw';
import UserOpSubHeading from 'ui/userOp/UserOpSubHeading';
const UserOp = () => { const UserOp = () => {
const router = useRouter(); const router = useRouter();
...@@ -90,7 +90,7 @@ const UserOp = () => { ...@@ -90,7 +90,7 @@ const UserOp = () => {
throwOnAbsentParamError(hash); throwOnAbsentParamError(hash);
throwOnResourceLoadError(userOpQuery); throwOnResourceLoadError(userOpQuery);
const titleSecondRow = <UserOpEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } fontFamily="heading"/>; const titleSecondRow = <UserOpSubHeading hash={ hash }/>;
return ( return (
<> <>
......
import { test, expect } from '@playwright/experimental-ct-react'; import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import * as mocks from 'mocks/account/verifiedAddresses'; 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 TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
...@@ -9,6 +11,14 @@ import VerifiedAddresses from './VerifiedAddresses'; ...@@ -9,6 +11,14 @@ import VerifiedAddresses from './VerifiedAddresses';
const VERIFIED_ADDRESS_URL = buildApiUrl('verified_addresses', { chainId: '1' }); const VERIFIED_ADDRESS_URL = buildApiUrl('verified_addresses', { chainId: '1' });
const TOKEN_INFO_APPLICATIONS_URL = buildApiUrl('token_info_applications', { chainId: '1', id: undefined }); 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 }) => { test.beforeEach(async({ context }) => {
await context.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => { await context.route(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, (route) => {
...@@ -28,6 +38,32 @@ test('base view +@mobile', async({ mount, page }) => { ...@@ -28,6 +38,32 @@ test('base view +@mobile', async({ mount, page }) => {
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATIONS_RESPONSE.DEFAULT), 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( const component = await mount(
<TestApp> <TestApp>
<VerifiedAddresses/> <VerifiedAddresses/>
...@@ -59,6 +95,10 @@ test('address verification flow', async({ mount, page }) => { ...@@ -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( await mount(
<TestApp> <TestApp>
<VerifiedAddresses/> <VerifiedAddresses/>
...@@ -100,6 +140,10 @@ test('application update flow', async({ mount, page }) => { ...@@ -100,6 +140,10 @@ test('application update flow', async({ mount, page }) => {
body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG), body: JSON.stringify(mocks.TOKEN_INFO_FORM_CONFIG),
})); }));
await page.route(USER_INFO_URL, (route) => route.fulfill({
body: JSON.stringify(profileMock.base),
}));
// PUT request // PUT request
await page.route(TOKEN_INFO_APPLICATION_URL, (route) => route.fulfill({ await page.route(TOKEN_INFO_APPLICATION_URL, (route) => route.fulfill({
body: JSON.stringify(mocks.TOKEN_INFO_APPLICATION.UPDATED_ITEM), 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 { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
...@@ -7,6 +7,7 @@ import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, Veri ...@@ -7,6 +7,7 @@ import type { VerifiedAddress, TokenInfoApplication, TokenInfoApplications, Veri
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken';
import { PAGE_TYPE_DICT } from 'lib/mixpanel/getPageType'; import { PAGE_TYPE_DICT } from 'lib/mixpanel/getPageType';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -37,16 +38,20 @@ const VerifiedAddresses = () => { ...@@ -37,16 +38,20 @@ const VerifiedAddresses = () => {
const modalProps = useDisclosure(); const modalProps = useDisclosure();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const userInfoQuery = useFetchProfileInfo();
const addressesQuery = useApiQuery('verified_addresses', { const addressesQuery = useApiQuery('verified_addresses', {
pathParams: { chainId: config.chain.id }, pathParams: { chainId: config.chain.id },
queryOptions: { queryOptions: {
placeholderData: { verifiedAddresses: Array(3).fill(VERIFIED_ADDRESS) }, placeholderData: { verifiedAddresses: Array(3).fill(VERIFIED_ADDRESS) },
enabled: Boolean(userInfoQuery.data?.email),
}, },
}); });
const applicationsQuery = useApiQuery('token_info_applications', { const applicationsQuery = useApiQuery('token_info_applications', {
pathParams: { chainId: config.chain.id, id: undefined }, pathParams: { chainId: config.chain.id, id: undefined },
queryOptions: { queryOptions: {
placeholderData: { submissions: Array(3).fill(TOKEN_INFO_APPLICATION) }, placeholderData: { submissions: Array(3).fill(TOKEN_INFO_APPLICATION) },
enabled: Boolean(userInfoQuery.data?.email),
select: (data) => { select: (data) => {
return { return {
...data, ...data,
...@@ -57,6 +62,7 @@ const VerifiedAddresses = () => { ...@@ -57,6 +62,7 @@ const VerifiedAddresses = () => {
}); });
const isLoading = addressesQuery.isPlaceholderData || applicationsQuery.isPlaceholderData; const isLoading = addressesQuery.isPlaceholderData || applicationsQuery.isPlaceholderData;
const userWithoutEmail = userInfoQuery.data && !userInfoQuery.data.email;
const handleGoBack = React.useCallback(() => { const handleGoBack = React.useCallback(() => {
setSelectedAddress(undefined); setSelectedAddress(undefined);
...@@ -100,13 +106,23 @@ const VerifiedAddresses = () => { ...@@ -100,13 +106,23 @@ const VerifiedAddresses = () => {
}); });
}, [ queryClient ]); }, [ queryClient ]);
const addButton = ( const addButton = (() => {
if (userWithoutEmail) {
return (
<Button size="lg" isDisabled mt={ 8 }>
Add address
</Button>
);
}
return (
<Skeleton mt={ 8 } isLoaded={ !isLoading } display="inline-block"> <Skeleton mt={ 8 } isLoaded={ !isLoading } display="inline-block">
<Button size="lg" onClick={ modalProps.onOpen }> <Button size="lg" onClick={ modalProps.onOpen }>
Add address Add address
</Button> </Button>
</Skeleton> </Skeleton>
); );
})();
const backLink = React.useMemo(() => { const backLink = React.useMemo(() => {
if (!selectedAddress) { if (!selectedAddress) {
...@@ -135,14 +151,23 @@ const VerifiedAddresses = () => { ...@@ -135,14 +151,23 @@ const VerifiedAddresses = () => {
); );
} }
const content = addressesQuery.data?.verifiedAddresses ? ( const content = (() => {
if (userWithoutEmail) {
return null;
}
if (addressesQuery.data?.verifiedAddresses) {
return (
<> <>
<Show below="lg" key="content-mobile" ssr={ false }> <Show below="lg" key="content-mobile" ssr={ false }>
{ addressesQuery.data.verifiedAddresses.map((item, index) => ( { addressesQuery.data.verifiedAddresses.map((item, index) => (
<VerifiedAddressesListItem <VerifiedAddressesListItem
key={ item.contractAddress + (isLoading ? index : '') } key={ item.contractAddress + (isLoading ? index : '') }
item={ item } item={ item }
application={ applicationsQuery.data?.submissions?.find(({ tokenAddress }) => tokenAddress.toLowerCase() === item.contractAddress.toLowerCase()) } application={
applicationsQuery.data?.submissions
?.find(({ tokenAddress }) => tokenAddress.toLowerCase() === item.contractAddress.toLowerCase())
}
onAdd={ handleItemAdd } onAdd={ handleItemAdd }
onEdit={ handleItemEdit } onEdit={ handleItemEdit }
isLoading={ isLoading } isLoading={ isLoading }
...@@ -159,11 +184,20 @@ const VerifiedAddresses = () => { ...@@ -159,11 +184,20 @@ const VerifiedAddresses = () => {
/> />
</Hide> </Hide>
</> </>
) : null; );
}
return null;
})();
return ( return (
<> <>
<PageTitle title="My verified addresses"/> <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 }> <AccountPageDescription allowCut={ false }>
<span> <span>
Verify ownership of a smart contract address to easily update information in Blockscout. Verify ownership of a smart contract address to easily update information in Blockscout.
...@@ -188,7 +222,7 @@ const VerifiedAddresses = () => { ...@@ -188,7 +222,7 @@ const VerifiedAddresses = () => {
<AdminSupportText mt={ 5 }/> <AdminSupportText mt={ 5 }/>
</AccountPageDescription> </AccountPageDescription>
<DataListDisplay <DataListDisplay
isError={ addressesQuery.isError || applicationsQuery.isError } isError={ userInfoQuery.isError || addressesQuery.isError || applicationsQuery.isError }
items={ addressesQuery.data?.verifiedAddresses } items={ addressesQuery.data?.verifiedAddresses }
content={ content } content={ content }
emptyText="" emptyText=""
......
...@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index'; ...@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address'; import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; 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 BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
...@@ -200,6 +201,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -200,6 +201,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
</TxEntity.Container> </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': { case 'user_operation': {
return ( return (
<UserOpEntity.Container> <UserOpEntity.Container>
......
...@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index'; ...@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address'; import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; 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 BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
...@@ -285,6 +286,30 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -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': { case 'user_operation': {
return ( return (
<> <>
......
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import { getRecentSearchKeywords } from 'lib/recentSearchKeywords'; import { getRecentSearchKeywords } from 'lib/recentSearchKeywords';
import SearchBarBackdrop from 'ui/snippets/searchBar/SearchBarBackdrop';
import SearchBarInput from 'ui/snippets/searchBar/SearchBarInput'; import SearchBarInput from 'ui/snippets/searchBar/SearchBarInput';
import SearchBarRecentKeywords from 'ui/snippets/searchBar/SearchBarRecentKeywords'; import SearchBarRecentKeywords from 'ui/snippets/searchBar/SearchBarRecentKeywords';
...@@ -66,9 +67,12 @@ const SearchResultsInput = ({ searchTerm, handleSubmit, handleSearchTermChange } ...@@ -66,9 +67,12 @@ const SearchResultsInput = ({ searchTerm, handleSubmit, handleSearchTermChange }
}; };
}, [ calculateMenuWidth ]); }, [ calculateMenuWidth ]);
const isSuggestOpen = isOpen && recentSearchKeywords.length > 0 && searchTerm.trim().length === 0;
return ( return (
<>
<Popover <Popover
isOpen={ isOpen && recentSearchKeywords.length > 0 && searchTerm.trim().length === 0 } isOpen={ isSuggestOpen }
autoFocus={ false } autoFocus={ false }
onClose={ onClose } onClose={ onClose }
placement="bottom-start" placement="bottom-start"
...@@ -85,6 +89,7 @@ const SearchResultsInput = ({ searchTerm, handleSubmit, handleSearchTermChange } ...@@ -85,6 +89,7 @@ const SearchResultsInput = ({ searchTerm, handleSubmit, handleSearchTermChange }
onHide={ handelHide } onHide={ handelHide }
onClear={ handleClear } onClear={ handleClear }
value={ searchTerm } value={ searchTerm }
isSuggestOpen={ isSuggestOpen }
/> />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }> <PopoverContent w={ `${ menuWidth.current }px` } maxH={{ base: '300px', lg: '500px' }} overflowY="scroll" ref={ menuRef }>
...@@ -93,6 +98,8 @@ const SearchResultsInput = ({ searchTerm, handleSubmit, handleSearchTermChange } ...@@ -93,6 +98,8 @@ const SearchResultsInput = ({ searchTerm, handleSubmit, handleSearchTermChange }
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
</Popover> </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 React from 'react';
import * as profileMock from 'mocks/user/profile';
import authFixture from 'playwright/fixtures/auth';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AccountActionsMenu from './AccountActionsMenu'; 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.use({ viewport: { width: 200, height: 200 } });
test.describe('with multiple items', async() => { test.describe('with multiple items', async() => {
...@@ -16,6 +28,12 @@ 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 }) => { test('base view', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
......
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ import React from 'react';
import type { ItemProps } from './types'; import type { ItemProps } from './types';
import config from 'configs/app'; import config from 'configs/app';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import useIsAccountActionAllowed from 'lib/hooks/useIsAccountActionAllowed'; import useIsAccountActionAllowed from 'lib/hooks/useIsAccountActionAllowed';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -27,6 +28,8 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => { ...@@ -27,6 +28,8 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
const isTxPage = router.pathname === '/tx/[hash]'; const isTxPage = router.pathname === '/tx/[hash]';
const isAccountActionAllowed = useIsAccountActionAllowed(); const isAccountActionAllowed = useIsAccountActionAllowed();
const userInfoQuery = useFetchProfileInfo();
const handleButtonClick = React.useCallback(() => { const handleButtonClick = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Address actions (more button)' }); mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Address actions (more button)' });
}, []); }, []);
...@@ -35,10 +38,12 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => { ...@@ -35,10 +38,12 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
return null; return null;
} }
const userWithoutEmail = userInfoQuery.data && !userInfoQuery.data.email;
const items = [ const items = [
{ {
render: (props: ItemProps) => <TokenInfoMenuItem { ...props }/>, 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' }/>, render: (props: ItemProps) => <PrivateTagMenuItem { ...props } entityType={ isTxPage ? 'tx' : 'address' }/>,
......
...@@ -37,6 +37,7 @@ const AdditionalInfoButton = ({ isOpen, onClick, className, isLoading }: Props, ...@@ -37,6 +37,7 @@ const AdditionalInfoButton = ({ isOpen, onClick, className, isLoading }: Props,
onClick={ onClick } onClick={ onClick }
cursor="pointer" cursor="pointer"
flexShrink={ 0 } flexShrink={ 0 }
aria-label="Transaction info"
> >
<IconSvg <IconSvg
name="info" name="info"
......
...@@ -36,8 +36,8 @@ test('status code 500', async({ mount }) => { ...@@ -36,8 +36,8 @@ test('status code 500', async({ mount }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('invalid tx hash', async({ mount }) => { test('tx not found', async({ mount }) => {
const error = { message: 'Invalid tx hash', cause: { status: 422, resource: 'tx' } } as Error; const error = { message: 'Not found', cause: { status: 404, resource: 'tx' } } as Error;
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<AppError error={ error }/> <AppError error={ error }/>
......
...@@ -11,8 +11,8 @@ import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload'; ...@@ -11,8 +11,8 @@ import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
import AppErrorIcon from './AppErrorIcon'; import AppErrorIcon from './AppErrorIcon';
import AppErrorTitle from './AppErrorTitle'; import AppErrorTitle from './AppErrorTitle';
import AppErrorBlockConsensus from './custom/AppErrorBlockConsensus'; import AppErrorBlockConsensus from './custom/AppErrorBlockConsensus';
import AppErrorInvalidTxHash from './custom/AppErrorInvalidTxHash';
import AppErrorTooManyRequests from './custom/AppErrorTooManyRequests'; import AppErrorTooManyRequests from './custom/AppErrorTooManyRequests';
import AppErrorTxNotFound from './custom/AppErrorTxNotFound';
interface Props { interface Props {
className?: string; className?: string;
...@@ -47,11 +47,11 @@ const AppError = ({ error, className }: Props) => { ...@@ -47,11 +47,11 @@ const AppError = ({ error, className }: Props) => {
undefined; undefined;
const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error); 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'); const isBlockConsensus = messageInPayload?.includes('Block lost consensus');
if (isInvalidTxHash) { if (isInvalidTxHash) {
return <AppErrorInvalidTxHash/>; return <AppErrorTxNotFound/>;
} }
if (isBlockConsensus) { if (isBlockConsensus) {
......
/* eslint-disable max-len */ /* 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 React from 'react';
import { route } from 'nextjs-routes';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import AppErrorTitle from '../AppErrorTitle'; import AppErrorTitle from '../AppErrorTitle';
const AppErrorInvalidTxHash = () => { const AppErrorTxNotFound = () => {
const textColor = useColorModeValue('gray.500', 'gray.400');
const snippet = { const snippet = {
borderColor: useColorModeValue('blackAlpha.300', 'whiteAlpha.300'), borderColor: useColorModeValue('blackAlpha.300', 'whiteAlpha.300'),
iconBg: useColorModeValue('blackAlpha.800', 'whiteAlpha.800'), iconBg: useColorModeValue('blackAlpha.800', 'whiteAlpha.800'),
...@@ -36,7 +37,7 @@ const AppErrorInvalidTxHash = () => { ...@@ -36,7 +37,7 @@ const AppErrorInvalidTxHash = () => {
</Flex> </Flex>
</Box> </Box>
<AppErrorTitle title="Sorry, we are unable to locate this transaction hash"/> <AppErrorTitle title="Sorry, we are unable to locate this transaction hash"/>
<OrderedList color={ textColor } mt={ 3 } spacing={ 3 }> <OrderedList mt={ 3 } spacing={ 3 }>
<ListItem> <ListItem>
If you have just submitted this transaction please wait for at least 30 seconds before refreshing this page. If you have just submitted this transaction please wait for at least 30 seconds before refreshing this page.
</ListItem> </ListItem>
...@@ -47,11 +48,22 @@ const AppErrorInvalidTxHash = () => { ...@@ -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. 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>
<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> </ListItem>
</OrderedList> </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) => { ...@@ -25,7 +25,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
}, [ hasCopied ]); }, [ hasCopied ]);
if (isLoading) { 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 ( return (
......
...@@ -9,9 +9,10 @@ const SCROLL_GRADIENT_HEIGHT = 48; ...@@ -9,9 +9,10 @@ const SCROLL_GRADIENT_HEIGHT = 48;
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
isLoading?: boolean; isLoading?: boolean;
type: 'tx' | 'user_op';
} }
const TxDetailsActions = ({ children, isLoading }: Props) => { const DetailsActionsWrapper = ({ children, isLoading, type }: Props) => {
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const [ hasScroll, setHasScroll ] = React.useState(false); const [ hasScroll, setHasScroll ] = React.useState(false);
...@@ -25,8 +26,8 @@ const TxDetailsActions = ({ children, isLoading }: Props) => { ...@@ -25,8 +26,8 @@ const TxDetailsActions = ({ children, isLoading }: Props) => {
return ( return (
<DetailsInfoItem <DetailsInfoItem
title="Transaction action" title={ `${ type === 'tx' ? 'Transaction' : 'User operation' } action` }
hint="Highlighted events of the transaction" hint={ `Highlighted events of the ${ type === 'tx' ? 'transaction' : 'user operation' }` }
note={ hasScroll ? 'Scroll to see more' : undefined } note={ hasScroll ? 'Scroll to see more' : undefined }
position="relative" position="relative"
isLoading={ isLoading } isLoading={ isLoading }
...@@ -47,4 +48,4 @@ const TxDetailsActions = ({ children, isLoading }: Props) => { ...@@ -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'; import React from 'react';
const DetailsInfoItemDivider = () => { interface Props {
className?: string;
id?: string;
}
const DetailsInfoItemDivider = ({ className, id }: Props) => {
return ( return (
<GridItem <GridItem
id={ id }
className={ className }
colSpan={{ base: undefined, lg: 2 }} colSpan={{ base: undefined, lg: 2 }}
mt={{ base: 2, lg: 3 }} mt={{ base: 2, lg: 3 }}
mb={{ base: 0, lg: 3 }} mb={{ base: 0, lg: 3 }}
...@@ -13,4 +20,4 @@ const DetailsInfoItemDivider = () => { ...@@ -13,4 +20,4 @@ const DetailsInfoItemDivider = () => {
); );
}; };
export default DetailsInfoItemDivider; export default chakra(DetailsInfoItemDivider);
import type { ThemingProps } from '@chakra-ui/react'; 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 React from 'react';
import type { UserTags } from 'types/api/addressParams'; import type { UserTags } from 'types/api/addressParams';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
...@@ -36,8 +37,12 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin ...@@ -36,8 +37,12 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
] ]
.filter(Boolean); .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) { if (tags.length === 0 && !contentAfter) {
return null; return metaSuitesPlaceholder;
} }
const content = (() => { const content = (() => {
...@@ -60,6 +65,7 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin ...@@ -60,6 +65,7 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
</Tag> </Tag>
)) ))
} }
{ metaSuitesPlaceholder }
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy> <Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger> <PopoverTrigger>
<Tag isLoading={ isLoading }onClick={ onToggle }>+{ tags.length - 1 }</Tag> <Tag isLoading={ isLoading }onClick={ onToggle }>+{ tags.length - 1 }</Tag>
...@@ -88,7 +94,9 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin ...@@ -88,7 +94,9 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
); );
} }
return tags.map((tag) => ( return (
<>
{ tags.map((tag) => (
<Tag <Tag
key={ tag.label } key={ tag.label }
isLoading={ isLoading } isLoading={ isLoading }
...@@ -99,7 +107,10 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin ...@@ -99,7 +107,10 @@ const EntityTags = ({ className, data, tagsBefore = [], tagsAfter = [], isLoadin
> >
{ tag.display_name } { tag.display_name }
</Tag> </Tag>
)); )) }
{ metaSuitesPlaceholder }
</>
);
})(); })();
return ( return (
......
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Flex, chakra, useColorModeValue, Skeleton } from '@chakra-ui/react'; import { Box, Flex, chakra, useColorModeValue, Skeleton } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
...@@ -12,9 +13,10 @@ interface Props { ...@@ -12,9 +13,10 @@ interface Props {
textareaMaxHeight?: string; textareaMaxHeight?: string;
showCopy?: boolean; showCopy?: boolean;
isLoading?: 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 // see issue in theme/components/Textarea.ts
const bgColor = useColorModeValue('#f5f5f6', '#1a1b1b'); const bgColor = useColorModeValue('#f5f5f6', '#1a1b1b');
return ( return (
...@@ -36,8 +38,10 @@ const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textare ...@@ -36,8 +38,10 @@ const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textare
borderRadius="md" borderRadius="md"
wordBreak="break-all" wordBreak="break-all"
whiteSpace="pre-wrap" whiteSpace="pre-wrap"
overflowX="hidden"
overflowY="auto" overflowY="auto"
isLoaded={ !isLoading } isLoaded={ !isLoading }
{ ...contentProps }
> >
{ data } { data }
</Skeleton> </Skeleton>
......
...@@ -2,8 +2,8 @@ import { chakra } from '@chakra-ui/react'; ...@@ -2,8 +2,8 @@ import { chakra } from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system'; import type { StyleProps } from '@chakra-ui/styled-system';
import React from 'react'; import React from 'react';
const TextSeparator = (props: StyleProps) => { const TextSeparator = ({ id, ...props }: StyleProps & { id?: string }) => {
return <chakra.span mx={ 3 } { ...props }>|</chakra.span>; return <chakra.span mx={ 3 } id={ id } { ...props }>|</chakra.span>;
}; };
export default React.memo(TextSeparator); export default React.memo(TextSeparator);
...@@ -32,6 +32,7 @@ const getConfig = () => { ...@@ -32,6 +32,7 @@ const getConfig = () => {
const wagmiConfig = defaultWagmiConfig({ const wagmiConfig = defaultWagmiConfig({
chains, chains,
projectId: feature.walletConnect.projectId, projectId: feature.walletConnect.projectId,
enableEmail: true,
}); });
createWeb3Modal({ createWeb3Modal({
......
...@@ -7,6 +7,7 @@ import * as cookies from 'lib/cookies'; ...@@ -7,6 +7,7 @@ import * as cookies from 'lib/cookies';
import AdbutlerBanner from './AdbutlerBanner'; import AdbutlerBanner from './AdbutlerBanner';
import CoinzillaBanner from './CoinzillaBanner'; import CoinzillaBanner from './CoinzillaBanner';
import GetitBanner from './GetitBanner';
import HypeBanner from './HypeBanner'; import HypeBanner from './HypeBanner';
import SliseBanner from './SliseBanner'; import SliseBanner from './SliseBanner';
...@@ -25,6 +26,8 @@ const AdBanner = ({ className, isLoading }: { className?: string; isLoading?: bo ...@@ -25,6 +26,8 @@ const AdBanner = ({ className, isLoading }: { className?: string; isLoading?: bo
return <AdbutlerBanner/>; return <AdbutlerBanner/>;
case 'coinzilla': case 'coinzilla':
return <CoinzillaBanner/>; return <CoinzillaBanner/>;
case 'getit':
return <GetitBanner/>;
case 'hype': case 'hype':
return <HypeBanner/>; return <HypeBanner/>;
case 'slise': 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) => { ...@@ -57,6 +57,7 @@ const Icon = (props: IconProps) => {
src={ props.token.icon_url ?? undefined } src={ props.token.icon_url ?? undefined }
alt={ `${ props.token.name || 'token' } logo` } alt={ `${ props.token.name || 'token' } logo` }
fallback={ <TokenLogoPlaceholder { ...styles }/> } fallback={ <TokenLogoPlaceholder { ...styles }/> }
fallbackStrategy={ props.token.icon_url ? 'onError' : 'beforeLoadOrError' }
/> />
); );
}; };
...@@ -66,7 +67,7 @@ type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps ...@@ -66,7 +67,7 @@ type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps
const Content = chakra((props: ContentProps) => { const Content = chakra((props: ContentProps) => {
const nameString = [ const nameString = [
!props.onlySymbol && (props.token.name ?? 'Unnamed token'), !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 })`, props.token.symbol && props.jointSymbol && !props.onlySymbol && `(${ props.token.symbol })`,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
......
...@@ -19,6 +19,7 @@ import CodeEditorSideBar, { CONTAINER_WIDTH as SIDE_BAR_WIDTH } from './CodeEdit ...@@ -19,6 +19,7 @@ import CodeEditorSideBar, { CONTAINER_WIDTH as SIDE_BAR_WIDTH } from './CodeEdit
import CodeEditorTabs from './CodeEditorTabs'; import CodeEditorTabs from './CodeEditorTabs';
import addExternalLibraryWarningDecoration from './utils/addExternalLibraryWarningDecoration'; import addExternalLibraryWarningDecoration from './utils/addExternalLibraryWarningDecoration';
import addFileImportDecorations from './utils/addFileImportDecorations'; import addFileImportDecorations from './utils/addFileImportDecorations';
import addMainContractCodeDecoration from './utils/addMainContractCodeDecoration';
import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile'; import getFullPathOfImportedFile from './utils/getFullPathOfImportedFile';
import * as themes from './utils/themes'; import * as themes from './utils/themes';
import useThemeColors from './utils/useThemeColors'; import useThemeColors from './utils/useThemeColors';
...@@ -42,9 +43,10 @@ interface Props { ...@@ -42,9 +43,10 @@ interface Props {
libraries?: Array<SmartContractExternalLibrary>; libraries?: Array<SmartContractExternalLibrary>;
language?: string; language?: string;
mainFile?: 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 [ instance, setInstance ] = React.useState<Monaco | undefined>();
const [ editor, setEditor ] = React.useState<monaco.editor.IStandaloneCodeEditor | undefined>(); const [ editor, setEditor ] = React.useState<monaco.editor.IStandaloneCodeEditor | undefined>();
const [ index, setIndex ] = React.useState(0); const [ index, setIndex ] = React.useState(0);
...@@ -82,9 +84,10 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props) ...@@ -82,9 +84,10 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
if (language === 'solidity') { if (language === 'solidity') {
loadedModels.concat(newModels) loadedModels.concat(newModels)
.forEach((models) => { .forEach((model) => {
addFileImportDecorations(models); contractName && mainFile === model.uri.path && addMainContractCodeDecoration(model, contractName, editor);
libraries?.length && addExternalLibraryWarningDecoration(models, libraries); addFileImportDecorations(model);
libraries?.length && addExternalLibraryWarningDecoration(model, libraries);
}); });
} }
...@@ -192,6 +195,13 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props) ...@@ -192,6 +195,13 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
'.monaco-editor .overflow-guard': { '.monaco-editor .overflow-guard': {
'border-bottom-left-radius': borderRadius, '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': { '.highlight': {
backgroundColor: themeColors['custom.findMatchHighlightBackground'], backgroundColor: themeColors['custom.findMatchHighlightBackground'],
}, },
...@@ -206,6 +216,18 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props) ...@@ -206,6 +216,18 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile }: Props)
'.risk-warning': { '.risk-warning': {
backgroundColor: themeColors['custom.riskWarning.background'], 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 ]); }), [ editorWidth, themeColors, borderRadius ]);
const renderErrorScreen = React.useCallback(() => { const renderErrorScreen = React.useCallback(() => {
......
...@@ -2,6 +2,8 @@ import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; ...@@ -2,6 +2,8 @@ import type * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import type { SmartContractExternalLibrary } from 'types/api/contract'; import type { SmartContractExternalLibrary } from 'types/api/contract';
import sortByEndLineNumberAsc from './sortByEndLineNumberAsc';
export default function addExternalLibraryWarningDecoration(model: monaco.editor.ITextModel, libraries: Array<SmartContractExternalLibrary>) { export default function addExternalLibraryWarningDecoration(model: monaco.editor.ITextModel, libraries: Array<SmartContractExternalLibrary>) {
const options: monaco.editor.IModelDecorationOptions = { const options: monaco.editor.IModelDecorationOptions = {
isWholeLine: true, isWholeLine: true,
...@@ -81,15 +83,3 @@ const getLibraryName = (model: monaco.editor.ITextModel) => (library: SmartContr ...@@ -81,15 +83,3 @@ const getLibraryName = (model: monaco.editor.ITextModel) => (library: SmartContr
return libraryName; 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 = { ...@@ -37,6 +37,8 @@ export const light = {
'custom.fileLink.hoverForeground': '#4299E1', // blue.400 'custom.fileLink.hoverForeground': '#4299E1', // blue.400
'custom.riskWarning.primaryBackground': '#FEEBCB', // orange.100 'custom.riskWarning.primaryBackground': '#FEEBCB', // orange.100
'custom.riskWarning.background': '#FFFAF0', // orange.50 '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, } as const,
}; };
...@@ -79,5 +81,7 @@ export const dark = { ...@@ -79,5 +81,7 @@ export const dark = {
'custom.fileLink.hoverForeground': '#4299E1', // blue.400 'custom.fileLink.hoverForeground': '#4299E1', // blue.400
'custom.riskWarning.primaryBackground': 'rgba(246, 173, 85, 0.3)', // orange.300 'custom.riskWarning.primaryBackground': 'rgba(246, 173, 85, 0.3)', // orange.300
'custom.riskWarning.background': 'rgba(246, 173, 85, 0.1)', // 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, } as const,
}; };
...@@ -10,7 +10,54 @@ test.describe('no url', () => { ...@@ -10,7 +10,54 @@ test.describe('no url', () => {
test('preview +@dark-mode', async({ mount }) => { test('preview +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <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>, </TestApp>,
); );
...@@ -35,7 +82,7 @@ test.describe('image', () => { ...@@ -35,7 +82,7 @@ test.describe('image', () => {
test('preview +@dark-mode', async({ mount }) => { test('preview +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<NftMedia url={ MEDIA_URL }/> <NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
</TestApp>, </TestApp>,
); );
...@@ -55,7 +102,7 @@ test('image preview hover', async({ mount, page }) => { ...@@ -55,7 +102,7 @@ test('image preview hover', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<NftMedia url={ MEDIA_URL } w="250px"/> <NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } w="250px"/>
</TestApp>, </TestApp>,
); );
...@@ -75,7 +122,7 @@ test('image fullscreen +@dark-mode +@mobile', async({ mount, page }) => { ...@@ -75,7 +122,7 @@ test('image fullscreen +@dark-mode +@mobile', async({ mount, page }) => {
}); });
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<NftMedia url={ MEDIA_URL } withFullscreen w="250px"/> <NftMedia animationUrl={ MEDIA_URL } imageUrl={ null } withFullscreen w="250px"/>
</TestApp>, </TestApp>,
); );
...@@ -107,7 +154,7 @@ test.describe('page', () => { ...@@ -107,7 +154,7 @@ test.describe('page', () => {
test('preview +@dark-mode', async({ mount }) => { test('preview +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<NftMedia url={ MEDIA_URL }/> <NftMedia animationUrl={ MEDIA_URL } imageUrl={ null }/>
</TestApp>, </TestApp>,
); );
......
...@@ -9,29 +9,31 @@ import NftImage from './NftImage'; ...@@ -9,29 +9,31 @@ import NftImage from './NftImage';
import NftImageFullscreen from './NftImageFullscreen'; import NftImageFullscreen from './NftImageFullscreen';
import NftVideo from './NftVideo'; import NftVideo from './NftVideo';
import NftVideoFullscreen from './NftVideoFullscreen'; import NftVideoFullscreen from './NftVideoFullscreen';
import useNftMediaType from './useNftMediaType'; import useNftMediaInfo from './useNftMediaInfo';
import { mediaStyleProps } from './utils'; import { mediaStyleProps } from './utils';
interface Props { interface Props {
url: string | null; imageUrl: string | null;
animationUrl: string | null;
className?: string; className?: string;
isLoading?: boolean; isLoading?: boolean;
withFullscreen?: 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 [ isMediaLoading, setIsMediaLoading ] = React.useState(true);
const [ isLoadingError, setIsLoadingError ] = React.useState(false); const [ isLoadingError, setIsLoadingError ] = React.useState(false);
const { ref, inView } = useInView({ triggerOnce: true }); const { ref, inView } = useInView({ triggerOnce: true });
const type = useNftMediaType(url, !isLoading && inView); const mediaInfo = useNftMediaInfo({ imageUrl, animationUrl, isEnabled: !isLoading && inView });
React.useEffect(() => { React.useEffect(() => {
if (!isLoading) { if (!isLoading && !mediaInfo) {
setIsMediaLoading(Boolean(url)); setIsMediaLoading(false);
setIsLoadingError(true);
} }
}, [ isLoading, url ]); }, [ isLoading, mediaInfo ]);
const handleMediaLoaded = React.useCallback(() => { const handleMediaLoaded = React.useCallback(() => {
setIsMediaLoading(false); setIsMediaLoading(false);
...@@ -45,11 +47,21 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => { ...@@ -45,11 +47,21 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const content = (() => { const content = (() => {
if (!url || isLoadingError) { if (isLoading) {
return null;
}
if (!mediaInfo || isLoadingError) {
const styleProps = withFullscreen ? {} : mediaStyleProps; const styleProps = withFullscreen ? {} : mediaStyleProps;
return <NftFallback { ...styleProps }/>; return <NftFallback { ...styleProps }/>;
} }
const { type, url } = mediaInfo;
if (!url) {
return null;
}
const props = { const props = {
src: url, src: url,
onLoad: handleMediaLoaded, onLoad: handleMediaLoaded,
...@@ -70,7 +82,13 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => { ...@@ -70,7 +82,13 @@ const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
})(); })();
const modal = (() => { const modal = (() => {
if (!url || !withFullscreen) { if (!mediaInfo || !withFullscreen || isLoading) {
return null;
}
const { type, url } = mediaInfo;
if (!url) {
return null; return null;
} }
......
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { StaticRoute } from 'nextjs-routes'; import type { StaticRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
...@@ -9,15 +10,57 @@ import useFetch from 'lib/hooks/useFetch'; ...@@ -9,15 +10,57 @@ import useFetch from 'lib/hooks/useFetch';
import type { MediaType } from './utils'; import type { MediaType } from './utils';
import { getPreliminaryMediaType } 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 fetch = useFetch();
const { data } = useQuery<unknown, ResourceError<unknown>, MediaType>({ return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ], queryKey: [ 'nft-media-type', url ],
queryFn: async() => { queryFn: async() => {
if (!url) { if (!url) {
return 'image'; return null;
} }
// media could be either image, gif, video or html-page // media could be either image, gif, video or html-page
...@@ -29,21 +72,27 @@ export default function useNftMediaType(url: string | null, isEnabled: boolean) ...@@ -29,21 +72,27 @@ export default function useNftMediaType(url: string | null, isEnabled: boolean)
const preliminaryType = getPreliminaryMediaType(url); const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) { if (preliminaryType) {
return preliminaryType; return { type: preliminaryType, url };
} }
const type = await (async() => {
try { try {
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } }); 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 response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' });
return 'type' in response ? response.type ?? 'image' : 'image'; return 'type' in response ? response.type : undefined;
} catch (error) { } catch (error) {
return 'image'; return;
} }
})();
if (!type) {
return null;
}
return { type, url };
}, },
enabled: isEnabled && Boolean(url), enabled,
staleTime: Infinity, staleTime: Infinity,
}); });
return data;
} }
...@@ -148,7 +148,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -148,7 +148,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}, [ queryClient, resourceName, router, scrollToTop ]); }, [ queryClient, resourceName, router, scrollToTop ]);
const onFilterChange = useCallback(<R extends PaginatedResources = Resource>(newFilters: PaginationFilters<R> | undefined) => { 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) { if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => { Object.entries(newFilters).forEach(([ key, value ]) => {
const isValidValue = typeof value === 'boolean' || (value && value.length); const isValidValue = typeof value === 'boolean' || (value && value.length);
...@@ -170,7 +170,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -170,7 +170,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
setPage(1); setPage(1);
setPageParams(INITIAL_PAGE_PARAMS); setPageParams(INITIAL_PAGE_PARAMS);
}); });
}, [ router, resource.filterFields, scrollToTop ]); }, [ router, resource, scrollToTop ]);
const onSortingChange = useCallback((newSorting: PaginationSorting<Resource> | undefined) => { const onSortingChange = useCallback((newSorting: PaginationSorting<Resource> | undefined) => {
const newQuery = { const newQuery = {
......
...@@ -3,7 +3,7 @@ import type { MarketplaceAppOverview } from 'types/client/marketplace'; ...@@ -3,7 +3,7 @@ import type { MarketplaceAppOverview } from 'types/client/marketplace';
import config from 'configs/app'; 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 Category = ApiCategory | 'app';
export type ItemsCategoriesMap = export type ItemsCategoriesMap =
...@@ -23,6 +23,7 @@ export const searchCategories: Array<{id: Category; title: string }> = [ ...@@ -23,6 +23,7 @@ export const searchCategories: Array<{id: Category; title: string }> = [
{ id: 'public_tag', title: 'Public tags' }, { id: 'public_tag', title: 'Public tags' },
{ id: 'transaction', title: 'Transactions' }, { id: 'transaction', title: 'Transactions' },
{ id: 'block', title: 'Blocks' }, { id: 'block', title: 'Blocks' },
{ id: 'blob', title: 'Blobs' },
]; ];
if (config.features.userOps.isEnabled) { if (config.features.userOps.isEnabled) {
...@@ -38,6 +39,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh ...@@ -38,6 +39,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' }, transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' },
block: { itemTitle: 'Block', itemTitleShort: 'Block' }, block: { itemTitle: 'Block', itemTitleShort: 'Block' },
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' }, user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
blob: { itemTitle: 'Blob', itemTitleShort: 'Blob' },
}; };
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined { export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
...@@ -67,5 +69,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C ...@@ -67,5 +69,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'user_operation': { case 'user_operation': {
return 'user_operation'; return 'user_operation';
} }
case 'blob': {
return 'blob';
}
} }
} }
...@@ -16,7 +16,7 @@ const ValidatorStatus = ({ state, isLoading }: Props) => { ...@@ -16,7 +16,7 @@ const ValidatorStatus = ({ state, isLoading }: Props) => {
case 'probation': case 'probation':
return <StatusTag type="pending" text="Probation" isLoading={ isLoading }/>; return <StatusTag type="pending" text="Probation" isLoading={ isLoading }/>;
case 'inactive': 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 }) => { ...@@ -54,7 +54,7 @@ test('search by token name +@mobile +@dark-mode', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); 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 }) => ...@@ -75,7 +75,7 @@ test('search by contract name +@mobile +@dark-mode', async({ mount, page }) =>
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); 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 }) => { ...@@ -97,7 +97,7 @@ test('search by name homepage +@dark-mode', async({ mount, page }) => {
<SearchBar isHomepage/> <SearchBar isHomepage/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); 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 }) => { ...@@ -117,7 +117,7 @@ test('search by tag +@mobile +@dark-mode', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); 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 }) => { ...@@ -137,7 +137,7 @@ test('search by address hash +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </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 page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); 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 }) => { ...@@ -159,7 +159,7 @@ test('search by block number +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </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 page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 600 } }); 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 }) => { ...@@ -179,7 +179,7 @@ test('search by block hash +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </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 page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); 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 }) => { ...@@ -199,7 +199,27 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </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 page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); 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 }) => { ...@@ -228,7 +248,7 @@ testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </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 page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); 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 }) => { ...@@ -251,7 +271,7 @@ test('search with view all link', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
...@@ -283,7 +303,7 @@ test('scroll suggest to category', async({ mount, page }) => { ...@@ -283,7 +303,7 @@ test('scroll suggest to category', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await page.getByRole('tab', { name: 'Addresses' }).click(); await page.getByRole('tab', { name: 'Addresses' }).click();
...@@ -345,7 +365,7 @@ base.describe('with apps', () => { ...@@ -345,7 +365,7 @@ base.describe('with apps', () => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); 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 _debounce from 'lodash/debounce';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
...@@ -12,6 +22,7 @@ import * as mixpanel from 'lib/mixpanel/index'; ...@@ -12,6 +22,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import { getRecentSearchKeywords, saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { getRecentSearchKeywords, saveToRecentKeywords } from 'lib/recentSearchKeywords';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import SearchBarBackdrop from './SearchBarBackdrop';
import SearchBarInput from './SearchBarInput'; import SearchBarInput from './SearchBarInput';
import SearchBarRecentKeywords from './SearchBarRecentKeywords'; import SearchBarRecentKeywords from './SearchBarRecentKeywords';
import SearchBarSuggest from './SearchBarSuggest/SearchBarSuggest'; import SearchBarSuggest from './SearchBarSuggest/SearchBarSuggest';
...@@ -106,6 +117,7 @@ const SearchBar = ({ isHomepage }: Props) => { ...@@ -106,6 +117,7 @@ const SearchBar = ({ isHomepage }: Props) => {
}, [ calculateMenuWidth ]); }, [ calculateMenuWidth ]);
return ( return (
<>
<Popover <Popover
isOpen={ isOpen && (searchTerm.trim().length > 0 || recentSearchKeywords.length > 0) } isOpen={ isOpen && (searchTerm.trim().length > 0 || recentSearchKeywords.length > 0) }
autoFocus={ false } autoFocus={ false }
...@@ -124,6 +136,7 @@ const SearchBar = ({ isHomepage }: Props) => { ...@@ -124,6 +136,7 @@ const SearchBar = ({ isHomepage }: Props) => {
onClear={ handleClear } onClear={ handleClear }
isHomepage={ isHomepage } isHomepage={ isHomepage }
value={ searchTerm } value={ searchTerm }
isSuggestOpen={ isOpen }
/> />
</PopoverTrigger> </PopoverTrigger>
<Portal> <Portal>
...@@ -169,6 +182,8 @@ const SearchBar = ({ isHomepage }: Props) => { ...@@ -169,6 +182,8 @@ const SearchBar = ({ isHomepage }: Props) => {
</PopoverContent> </PopoverContent>
</Portal> </Portal>
</Popover> </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 { ...@@ -16,10 +16,14 @@ interface Props {
onHide?: () => void; onHide?: () => void;
onClear: () => void; onClear: () => void;
isHomepage?: boolean; isHomepage?: boolean;
isSuggestOpen?: boolean;
value: string; 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); const innerRef = React.useRef<HTMLFormElement>(null);
React.useImperativeHandle(ref, () => innerRef.current as HTMLFormElement, []); React.useImperativeHandle(ref, () => innerRef.current as HTMLFormElement, []);
const [ isSticky, setIsSticky ] = React.useState(false); const [ isSticky, setIsSticky ] = React.useState(false);
...@@ -71,10 +75,10 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid ...@@ -71,10 +75,10 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid
w="100%" w="100%"
backgroundColor={ bgColor } backgroundColor={ bgColor }
borderRadius={{ base: isHomepage ? 'base' : 'none', lg: 'base' }} 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 }} top={{ base: isHomepage ? 0 : 55, lg: 0 }}
left="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 }} paddingX={{ base: isHomepage ? 0 : 4, lg: 0 }}
paddingTop={{ base: isHomepage ? 0 : 1, lg: 0 }} paddingTop={{ base: isHomepage ? 0 : 1, lg: 0 }}
paddingBottom={{ base: isHomepage ? 0 : 2, 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'; ...@@ -7,6 +7,7 @@ import type { SearchResultItem } from 'types/api/search';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import SearchBarSuggestAddress from './SearchBarSuggestAddress'; import SearchBarSuggestAddress from './SearchBarSuggestAddress';
import SearchBarSuggestBlob from './SearchBarSuggestBlob';
import SearchBarSuggestBlock from './SearchBarSuggestBlock'; import SearchBarSuggestBlock from './SearchBarSuggestBlock';
import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel'; import SearchBarSuggestLabel from './SearchBarSuggestLabel';
...@@ -42,6 +43,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -42,6 +43,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'user_operation': { case 'user_operation': {
return route({ pathname: '/op/[hash]', query: { hash: data.user_operation_hash } }); 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) => ...@@ -67,6 +71,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'user_operation': { case 'user_operation': {
return <SearchBarSuggestUserOp data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>; 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) => { ...@@ -68,7 +68,7 @@ const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => {
> >
{ items.map((item, index) => ( { items.map((item, index) => (
<TokenInventoryItem <TokenInventoryItem
key={ token.address + '_' + item.id + (inventoryQuery.isPlaceholderData ? '_' + index : '') } key={ item.id + '_' + index + (inventoryQuery.isPlaceholderData ? '_' + 'placeholder' : '') }
item={ item } item={ item }
isLoading={ inventoryQuery.isPlaceholderData || tokenQuery.isPlaceholderData } isLoading={ inventoryQuery.isPlaceholderData || tokenQuery.isPlaceholderData }
token={ token } token={ token }
......
...@@ -20,7 +20,8 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => { ...@@ -20,7 +20,8 @@ const TokenInventoryItem = ({ item, token, isLoading }: Props) => {
const mediaElement = ( const mediaElement = (
<NftMedia <NftMedia
mb="18px" mb="18px"
url={ item.animation_url || item.image_url } animationUrl={ item.animation_url }
imageUrl={ item.image_url }
isLoading={ isLoading } isLoading={ isLoading }
/> />
); );
......
...@@ -17,10 +17,12 @@ const TriggerButton = ({ onClick }: Props, ref: React.ForwardedRef<HTMLButtonEle ...@@ -17,10 +17,12 @@ const TriggerButton = ({ onClick }: Props, ref: React.ForwardedRef<HTMLButtonEle
onClick={ onClick } onClick={ onClick }
aria-label="Show project info" aria-label="Show project info"
fontWeight={ 500 } fontWeight={ 500 }
px={ 2 } lineHeight={ 6 }
pl={ 1 }
pr={ 2 }
h="32px" h="32px"
> >
<IconSvg name="rocket" boxSize={ 5 } mr={ 1 }/> <IconSvg name="info" boxSize={ 6 } mr={ 1 }/>
<span>Info</span> <span>Info</span>
</Button> </Button>
); );
......
...@@ -74,7 +74,8 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => { ...@@ -74,7 +74,8 @@ const TokenInstanceDetails = ({ data, token, scrollRef, isLoading }: Props) => {
<TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/> <TokenNftMarketplaces isLoading={ isLoading } hash={ token.address } id={ data.id }/>
</Grid> </Grid>
<NftMedia <NftMedia
url={ data.animation_url || data.image_url } animationUrl={ data.animation_url }
imageUrl={ data.image_url }
w="250px" w="250px"
flexShrink={ 0 } flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }} 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'; ...@@ -7,12 +7,14 @@ import type { Transaction } from 'types/api/transaction';
import { SECOND } from 'lib/consts'; import { SECOND } from 'lib/consts';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import hexToDecimal from 'lib/hexToDecimal'; import hexToDecimal from 'lib/hexToDecimal';
import { publicClient } from 'lib/web3/client'; import { publicClient } from 'lib/web3/client';
import { GET_BLOCK, GET_TRANSACTION, GET_TRANSACTION_RECEIPT, GET_TRANSACTION_CONFIRMATIONS } from 'stubs/RPC'; import { GET_BLOCK, GET_TRANSACTION, GET_TRANSACTION_RECEIPT, GET_TRANSACTION_CONFIRMATIONS } from 'stubs/RPC';
import { unknownAddress } from 'ui/shared/address/utils'; import { unknownAddress } from 'ui/shared/address/utils';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning'; import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
import TestnetWarning from 'ui/shared/alerts/TestnetWarning'; import TestnetWarning from 'ui/shared/alerts/TestnetWarning';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TxInfo from './details/TxInfo'; import TxInfo from './details/TxInfo';
...@@ -141,8 +143,8 @@ const TxDetailsDegraded = ({ hash, txQuery }: Props) => { ...@@ -141,8 +143,8 @@ const TxDetailsDegraded = ({ hash, txQuery }: Props) => {
}, [ txQuery.setRefetchOnError ]); }, [ txQuery.setRefetchOnError ]);
if (!query.data) { if (!query.data) {
if (originalError?.status === 404) { if (originalError && isCustomAppError(originalError)) {
throw Error('Not found', { cause: { status: 404 } as unknown as Error }); throwOnResourceLoadError({ resource: 'tx', error: originalError, isError: true });
} }
return <DataFetchAlert/>; return <DataFetchAlert/>;
......
...@@ -5,10 +5,10 @@ import config from 'configs/app'; ...@@ -5,10 +5,10 @@ import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { TX_INTERPRETATION } from 'stubs/txInterpretation'; import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; 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 TxEntity from 'ui/shared/entities/tx/TxEntity';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
import { TX_ACTIONS_BLOCK_ID } from 'ui/tx/details/txDetailsActions/TxDetailsActionsWrapper'; import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation';
import TxInterpretation from 'ui/tx/interpretation/TxInterpretation';
import type { TxQuery } from './useTxQuery'; 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) => { ...@@ -21,6 +21,7 @@ const TxDetailsOther = ({ nonce, type, position }: Props) => {
<Text as="span" fontWeight="500">Txn type: </Text> <Text as="span" fontWeight="500">Txn type: </Text>
<Text fontWeight="600" as="span">{ type }</Text> <Text fontWeight="600" as="span">{ type }</Text>
{ type === 2 && <Text fontWeight="400" as="span" ml={ 1 } variant="secondary">(EIP-1559)</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>
), ),
<Box key="nonce"> <Box key="nonce">
......
...@@ -116,6 +116,22 @@ test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -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({ const l2Test = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
......
...@@ -45,6 +45,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability'; ...@@ -45,6 +45,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions'; import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
import TxDetailsBurntFees from 'ui/tx/details/TxDetailsBurntFees';
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas'; import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice'; import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
import TxDetailsOther from 'ui/tx/details/TxDetailsOther'; import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
...@@ -108,6 +109,15 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -108,6 +109,15 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
return ( return (
<Grid columnGap={ 8 } rowGap={{ base: 3, lg: 3 }} templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}> <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 && ( { socketStatus && (
<GridItem colSpan={{ base: undefined, lg: 2 }} mb={ 2 }> <GridItem colSpan={{ base: undefined, lg: 2 }} mb={ 2 }>
<TxSocketAlert status={ socketStatus }/> <TxSocketAlert status={ socketStatus }/>
...@@ -124,6 +134,13 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -124,6 +134,13 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
<HashStringShortenDynamic hash={ data.hash }/> <HashStringShortenDynamic hash={ data.hash }/>
</Skeleton> </Skeleton>
<CopyToClipboard text={ data.hash } isLoading={ isLoading }/> <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>
<DetailsInfoItem <DetailsInfoItem
title={ rollupFeature.isEnabled && rollupFeature.type === 'zkEvm' ? 'L2 status and method' : 'Status and method' } title={ rollupFeature.isEnabled && rollupFeature.type === 'zkEvm' ? 'L2 status and method' : 'Status and method' }
...@@ -357,7 +374,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -357,7 +374,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
{ !config.UI.views.tx.hiddenFields?.tx_fee && ( { !config.UI.views.tx.hiddenFields?.tx_fee && (
<DetailsInfoItem <DetailsInfoItem
title="Transaction fee" title="Transaction fee"
hint="Total transaction fee" hint={ data.blob_gas_used ? 'Transaction fee without blob fee' : 'Total transaction fee' }
isLoading={ isLoading } isLoading={ isLoading }
> >
{ data.stability_fee ? ( { data.stability_fee ? (
...@@ -422,21 +439,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -422,21 +439,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ data.tx_burnt_fee && !config.UI.views.tx.hiddenFields?.burnt_fees && !(rollupFeature.isEnabled && rollupFeature.type === 'optimistic') && ( <TxDetailsBurntFees data={ data } isLoading={ isLoading }/>
<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>
) }
{ rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && ( { rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && (
<> <>
{ data.l1_gas_used && ( { data.l1_gas_used && (
...@@ -502,6 +505,50 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -502,6 +505,50 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
{ isExpanded && ( { isExpanded && (
<> <>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/> <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 }/> <TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position }/>
<DetailsInfoItem <DetailsInfoItem
title="Raw input" title="Raw input"
......
...@@ -2,10 +2,9 @@ import React from 'react'; ...@@ -2,10 +2,9 @@ import React from 'react';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { TX_INTERPRETATION } from 'stubs/txInterpretation'; import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import DetailsActionsWrapper from 'ui/shared/DetailsActionsWrapper';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import TxInterpretation from 'ui/tx/interpretation/TxInterpretation'; import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation';
import TxDetailsActionsWrapper from './TxDetailsActionsWrapper';
interface Props { interface Props {
hash?: string; hash?: string;
...@@ -30,7 +29,7 @@ const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => { ...@@ -30,7 +29,7 @@ const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => {
return ( return (
<> <>
<TxDetailsActionsWrapper isLoading={ isTxDataLoading || txInterpretationQuery.isPlaceholderData }> <DetailsActionsWrapper isLoading={ isTxDataLoading || txInterpretationQuery.isPlaceholderData } type="tx">
{ actions.map((action, index: number) => ( { actions.map((action, index: number) => (
<TxInterpretation <TxInterpretation
key={ index } key={ index }
...@@ -39,7 +38,7 @@ const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => { ...@@ -39,7 +38,7 @@ const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => {
/> />
), ),
) } ) }
</TxDetailsActionsWrapper> </DetailsActionsWrapper>
<DetailsInfoItemDivider/> <DetailsInfoItemDivider/>
</> </>
); );
......
...@@ -2,10 +2,10 @@ import React from 'react'; ...@@ -2,10 +2,10 @@ import React from 'react';
import type { TxAction } from 'types/api/txAction'; import type { TxAction } from 'types/api/txAction';
import DetailsActionsWrapper from 'ui/shared/DetailsActionsWrapper';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import TxDetailsAction from './TxDetailsAction'; import TxDetailsAction from './TxDetailsAction';
import TxDetailsActionsWrapper from './TxDetailsActionsWrapper';
interface Props { interface Props {
actions: Array<TxAction>; actions: Array<TxAction>;
...@@ -15,9 +15,9 @@ interface Props { ...@@ -15,9 +15,9 @@ interface Props {
const TxDetailsActionsRaw = ({ actions, isLoading }: Props) => { const TxDetailsActionsRaw = ({ actions, isLoading }: Props) => {
return ( return (
<> <>
<TxDetailsActionsWrapper isLoading={ isLoading }> <DetailsActionsWrapper isLoading={ isLoading } type="tx">
{ actions.map((action, index: number) => <TxDetailsAction key={ index } action={ action }/>) } { actions.map((action, index: number) => <TxDetailsAction key={ index } action={ action }/>) }
</TxDetailsActionsWrapper> </DetailsActionsWrapper>
<DetailsInfoItemDivider/> <DetailsInfoItemDivider/>
</> </>
); );
......
...@@ -28,7 +28,7 @@ const TxStateTableItem = ({ data, isLoading }: Props) => { ...@@ -28,7 +28,7 @@ const TxStateTableItem = ({ data, isLoading }: Props) => {
isLoading={ isLoading } isLoading={ isLoading }
truncation="constant" truncation="constant"
my="7px" my="7px"
w="min-content" w="100%"
/> />
</Td> </Td>
<Td isNumeric><Box py="7px">{ before }</Box></Td> <Td isNumeric><Box py="7px">{ before }</Box></Td>
......
...@@ -10,6 +10,7 @@ import { route } from 'nextjs-routes'; ...@@ -10,6 +10,7 @@ import { route } from 'nextjs-routes';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
...@@ -42,7 +43,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => { ...@@ -42,7 +43,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
}, [ data, router ]); }, [ data, router ]);
if (isError) { if (isError) {
if (error?.status === 404 || error?.status === 422) { if (isCustomAppError(error)) {
throwOnResourceLoadError({ isError, error }); throwOnResourceLoadError({ isError, error });
} }
...@@ -86,7 +87,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => { ...@@ -86,7 +87,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
title="Timestamp" title="Timestamp"
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/> { data.timestamp ? <DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/> : 'Undefined' }
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
title="Verify tx hash" title="Verify tx hash"
...@@ -98,7 +99,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => { ...@@ -98,7 +99,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
hash={ data.verify_tx_hash } hash={ data.verify_tx_hash }
maxW="100%" maxW="100%"
/> />
) : <Text>pending</Text> } ) : <Text>Pending</Text> }
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
title="Transactions" title="Transactions"
......
...@@ -18,7 +18,7 @@ const rollupFeature = config.features.rollup; ...@@ -18,7 +18,7 @@ const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean }; type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean };
const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => { 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') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null; return null;
...@@ -61,7 +61,7 @@ const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => { ...@@ -61,7 +61,7 @@ const ZkEvmTxnBatchesListItem = ({ item, isLoading }: Props) => {
</LinkInternal> </LinkInternal>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Verify Tx Has</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Verify tx hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
{ item.verify_tx_hash ? ( { item.verify_tx_hash ? (
<TxEntityL1 <TxEntityL1
......
...@@ -22,7 +22,7 @@ const TxnBatchesTable = ({ items, top, isLoading }: Props) => { ...@@ -22,7 +22,7 @@ const TxnBatchesTable = ({ items, top, isLoading }: Props) => {
<Th width="33%">Status</Th> <Th width="33%">Status</Th>
<Th width="150px">Age</Th> <Th width="150px">Age</Th>
<Th width="150px">Txn count</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> <Th width="230px">Sequence hash</Th>
</Tr> </Tr>
</Thead> </Thead>
......
...@@ -17,7 +17,7 @@ const rollupFeature = config.features.rollup; ...@@ -17,7 +17,7 @@ const rollupFeature = config.features.rollup;
type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean }; type Props = { item: ZkEvmL2TxnBatchesItem; isLoading?: boolean };
const TxnBatchesTableItem = ({ item, isLoading }: Props) => { 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') { if (!rollupFeature.isEnabled || rollupFeature.type !== 'zkEvm') {
return null; 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) = ...@@ -58,7 +58,7 @@ const TxAdditionalInfo = ({ hash, tx, isMobile, isLoading, className }: Props) =
<AdditionalInfoButton isOpen={ isOpen } isLoading={ isLoading } className={ className }/> <AdditionalInfoButton isOpen={ isOpen } isLoading={ isLoading } className={ className }/>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent border="1px solid" borderColor="divider"> <PopoverContent border="1px solid" borderColor="divider">
<PopoverBody> <PopoverBody fontWeight={ 400 } fontSize="sm">
{ content } { content }
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
......
...@@ -10,6 +10,7 @@ import config from 'configs/app'; ...@@ -10,6 +10,7 @@ import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator'; import TextSeparator from 'ui/shared/TextSeparator';
import TxFeeStability from 'ui/shared/tx/TxFeeStability'; import TxFeeStability from 'ui/shared/tx/TxFeeStability';
...@@ -26,12 +27,34 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => { ...@@ -26,12 +27,34 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
color: 'gray.500', color: 'gray.500',
fontWeight: 600, fontWeight: 600,
marginBottom: 3, marginBottom: 3,
fontSize: 'sm',
}; };
return ( return (
<> <>
<Heading as="h4" size="sm" mb={ 6 }>Additional info </Heading> <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 && ( { !config.UI.views.tx.hiddenFields?.tx_fee && (
<Box { ...sectionProps } mb={ 4 }> <Box { ...sectionProps } mb={ 4 }>
{ (tx.stability_fee !== undefined || tx.fee.value !== null) && ( { (tx.stability_fee !== undefined || tx.fee.value !== null) && (
...@@ -73,23 +96,24 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => { ...@@ -73,23 +96,24 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
{ tx.base_fee_per_gas !== null && ( { tx.base_fee_per_gas !== null && (
<Box> <Box>
<Text as="span" fontWeight="500">Base: </Text> <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> </Box>
) } ) }
{ tx.max_fee_per_gas !== null && ( { tx.max_fee_per_gas !== null && (
<Box mt={ 1 }> <Box mt={ 1 }>
<Text as="span" fontWeight="500">Max: </Text> <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> </Box>
) } ) }
{ tx.max_priority_fee_per_gas !== null && ( { tx.max_priority_fee_per_gas !== null && (
<Box mt={ 1 }> <Box mt={ 1 }>
<Text as="span" fontWeight="500">Max priority: </Text> <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> </Box>
) } ) }
{ !(tx.blob_versioned_hashes && tx.blob_versioned_hashes.length > 0) && (
<Box { ...sectionProps } mb={ 4 }> <Box { ...sectionProps } mb={ 4 }>
<Text { ...sectionTitleProps }>Others</Text> <Text { ...sectionTitleProps }>Others</Text>
<Box> <Box>
...@@ -106,7 +130,8 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => { ...@@ -106,7 +130,8 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
<Text fontWeight="600" as="span">{ tx.position }</Text> <Text fontWeight="600" as="span">{ tx.position }</Text>
</Box> </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 { ...@@ -9,7 +9,16 @@ export interface Props {
isLoading?: boolean; 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 TxType = ({ types, isLoading }: Props) => {
const typeToShow = types.sort((t1, t2) => TYPES_ORDER.indexOf(t1) - TYPES_ORDER.indexOf(t2))[0]; const typeToShow = types.sort((t1, t2) => TYPES_ORDER.indexOf(t1) - TYPES_ORDER.indexOf(t2))[0];
...@@ -22,6 +31,10 @@ const TxType = ({ types, isLoading }: Props) => { ...@@ -22,6 +31,10 @@ const TxType = ({ types, isLoading }: Props) => {
label = 'Contract call'; label = 'Contract call';
colorScheme = 'blue'; colorScheme = 'blue';
break; break;
case 'blob_transaction':
label = 'Blob txn';
colorScheme = 'yellow';
break;
case 'contract_creation': case 'contract_creation':
label = 'Contract creation'; label = 'Contract creation';
colorScheme = 'blue'; 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 type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; 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 type { ResourceError } from 'lib/api/resources';
import compareBns from 'lib/bigint/compareBns';
import * as cookies from 'lib/cookies'; import * as cookies from 'lib/cookies';
import type { Option } from 'ui/shared/sort/Sort'; import type { Option } from 'ui/shared/sort/Sort';
import sortTxs from './sortTxs';
export const SORT_OPTIONS: Array<Option<TransactionsSortingValue>> = [ export const SORT_OPTIONS: Array<Option<TransactionsSortingValue>> = [
{ title: 'Default', id: undefined }, { title: 'Default', id: undefined },
{ title: 'Value ascending', id: 'value-asc' }, { title: 'Value ascending', id: 'value-asc' },
...@@ -23,21 +24,6 @@ type HookResult = UseQueryResult<TxsResponse, ResourceError<unknown>> & { ...@@ -23,21 +24,6 @@ type HookResult = UseQueryResult<TxsResponse, ResourceError<unknown>> & {
setSortByValue: (value: SortingValue) => void; 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( export default function useTxsSort(
queryResult: UseQueryResult<TxsResponse, ResourceError<unknown>>, queryResult: UseQueryResult<TxsResponse, ResourceError<unknown>>,
): HookResult { ): HookResult {
......
...@@ -12,6 +12,7 @@ import { WEI, WEI_IN_GWEI } from 'lib/consts'; ...@@ -12,6 +12,7 @@ import { WEI, WEI_IN_GWEI } from 'lib/consts';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import { space } from 'lib/html-entities'; import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
...@@ -26,6 +27,8 @@ import UserOpSponsorType from 'ui/shared/userOps/UserOpSponsorType'; ...@@ -26,6 +27,8 @@ import UserOpSponsorType from 'ui/shared/userOps/UserOpSponsorType';
import UserOpStatus from 'ui/shared/userOps/UserOpStatus'; import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
import UserOpDetailsActions from './UserOpDetailsActions';
interface Props { interface Props {
query: UseQueryResult<UserOp, ResourceError>; query: UseQueryResult<UserOp, ResourceError>;
} }
...@@ -46,7 +49,7 @@ const UserOpDetails = ({ query }: Props) => { ...@@ -46,7 +49,7 @@ const UserOpDetails = ({ query }: Props) => {
}, []); }, []);
if (isError) { if (isError) {
if (error?.status === 400 || error?.status === 404 || error?.status === 422) { if (error?.status === 400 || isCustomAppError(error)) {
throwOnResourceLoadError({ isError, error }); throwOnResourceLoadError({ isError, error });
} }
...@@ -168,6 +171,8 @@ const UserOpDetails = ({ query }: Props) => { ...@@ -168,6 +171,8 @@ const UserOpDetails = ({ query }: Props) => {
<AddressStringOrParam address={ data.entry_point } isLoading={ isPlaceholderData }/> <AddressStringOrParam address={ data.entry_point } isLoading={ isPlaceholderData }/>
</DetailsInfoItem> </DetailsInfoItem>
{ config.features.txInterpretation.isEnabled && <UserOpDetailsActions hash={ data.hash } isUserOpDataLoading={ isPlaceholderData }/> }
{ /* CUT */ } { /* CUT */ }
<GridItem colSpan={{ base: undefined, lg: 2 }}> <GridItem colSpan={{ base: undefined, lg: 2 }}>
<Element name={ CUT_LINK_NAME }> <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'; ...@@ -7,7 +7,7 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet';
// order is taken from the ERC-4337 standard // order is taken from the ERC-4337 standard
// eslint-disable-next-line max-len // 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 { interface Props {
rawData?: UserOp['raw']; rawData?: UserOp['raw'];
...@@ -20,7 +20,10 @@ const UserOpRaw = ({ rawData, isLoading }: Props) => { ...@@ -20,7 +20,10 @@ const UserOpRaw = ({ rawData, isLoading }: Props) => {
} }
const text = JSON.stringify(KEYS_ORDER.reduce((res: UserOp['raw'], key: keyof UserOp['raw']) => { 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; return res;
}, {} as UserOp['raw']), undefined, 4); }, {} 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) => { ...@@ -36,7 +36,7 @@ const ValidatorsFilter = ({ onChange, defaultValue, isActive }: Props) => {
<MenuItemOption value="all">All</MenuItemOption> <MenuItemOption value="all">All</MenuItemOption>
<MenuItemOption value="active">Active</MenuItemOption> <MenuItemOption value="active">Active</MenuItemOption>
<MenuItemOption value="probation">Probation</MenuItemOption> <MenuItemOption value="probation">Probation</MenuItemOption>
<MenuItemOption value="inactive">Failed</MenuItemOption> <MenuItemOption value="inactive">Inactive</MenuItemOption>
</MenuOptionGroup> </MenuOptionGroup>
</MenuList> </MenuList>
</Menu> </Menu>
......
...@@ -29,7 +29,11 @@ const OptimisticL2WithdrawalsTable = ({ items, top, isLoading }: Props) => { ...@@ -29,7 +29,11 @@ const OptimisticL2WithdrawalsTable = ({ items, top, isLoading }: Props) => {
</Thead> </Thead>
<Tbody> <Tbody>
{ items.map((item, index) => ( { 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> </Tbody>
</Table> </Table>
......
...@@ -7635,6 +7635,15 @@ axios@^1.4.0: ...@@ -7635,6 +7635,15 @@ axios@^1.4.0:
form-data "^4.0.0" form-data "^4.0.0"
proxy-from-env "^1.1.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: axobject-query@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
...@@ -8220,6 +8229,11 @@ comment-parser@^1.1.2: ...@@ -8220,6 +8229,11 @@ comment-parser@^1.1.2:
resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.3.1.tgz#3d7ea3adaf9345594aedee6563f422348f165c1b" resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.3.1.tgz#3d7ea3adaf9345594aedee6563f422348f165c1b"
integrity sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA== 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: compute-scroll-into-view@1.0.20:
version "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" 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: ...@@ -8319,7 +8333,7 @@ cross-fetch@^3.1.4, cross-fetch@^3.1.5:
dependencies: dependencies:
node-fetch "2.6.7" 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" version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
...@@ -9969,6 +9983,11 @@ follow-redirects@^1.15.0: ...@@ -9969,6 +9983,11 @@ follow-redirects@^1.15.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== 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: fontfaceobserver@2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/fontfaceobserver/-/fontfaceobserver-2.1.0.tgz#e2705d293e2c585a6531c2a722905657317a2991" resolved "https://registry.yarnpkg.com/fontfaceobserver/-/fontfaceobserver-2.1.0.tgz#e2705d293e2c585a6531c2a722905657317a2991"
...@@ -10106,6 +10125,13 @@ gcp-metadata@^5.0.0: ...@@ -10106,6 +10125,13 @@ gcp-metadata@^5.0.0:
gaxios "^5.0.0" gaxios "^5.0.0"
json-bigint "^1.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: gensync@^1.0.0-beta.2:
version "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" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
...@@ -10167,6 +10193,15 @@ get-tsconfig@^4.5.0: ...@@ -10167,6 +10193,15 @@ get-tsconfig@^4.5.0:
resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.5.0.tgz#6d52d1c7b299bd3ee9cd7638561653399ac77b0f" resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.5.0.tgz#6d52d1c7b299bd3ee9cd7638561653399ac77b0f"
integrity sha512-MjhiaIWCJ1sAU4pIQ5i5OfOuHHxVo1oYeNsWTON7jxYkod8pHocXeh+SSbmu5OZZZK73B6cbJ2XADzXehLyovQ== 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: github-from-package@0.0.0:
version "0.0.0" version "0.0.0"
resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" 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: ...@@ -10577,6 +10612,11 @@ iconv-lite@0.6, iconv-lite@0.6.3:
dependencies: dependencies:
safer-buffer ">= 2.1.2 < 3.0.0" 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: icss-utils@^5.0.0, icss-utils@^5.1.0:
version "5.1.0" version "5.1.0"
resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae"
...@@ -11422,6 +11462,17 @@ jest-snapshot@^29.3.1: ...@@ -11422,6 +11462,17 @@ jest-snapshot@^29.3.1:
pretty-format "^29.3.1" pretty-format "^29.3.1"
semver "^7.3.5" 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: jest-util@^29.0.0, jest-util@^29.3.1:
version "29.3.1" version "29.3.1"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.3.1.tgz#1dda51e378bbcb7e3bc9d8ab651445591ed373e1" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.3.1.tgz#1dda51e378bbcb7e3bc9d8ab651445591ed373e1"
...@@ -11751,6 +11802,11 @@ lilconfig@2.0.5: ...@@ -11751,6 +11802,11 @@ lilconfig@2.0.5:
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.5.tgz#19e57fd06ccc3848fd1891655b5a447092225b25" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.5.tgz#19e57fd06ccc3848fd1891655b5a447092225b25"
integrity sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg== 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: lines-and-columns@^1.1.6:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
...@@ -11876,6 +11932,11 @@ lit@3.1.0: ...@@ -11876,6 +11932,11 @@ lit@3.1.0:
lit-element "^4.0.0" lit-element "^4.0.0"
lit-html "^3.1.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: locate-path@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
...@@ -11999,6 +12060,11 @@ lz-string@^1.5.0: ...@@ -11999,6 +12060,11 @@ lz-string@^1.5.0:
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== 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: magic-string@^0.30.5:
version "0.30.5" version "0.30.5"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9"
...@@ -13035,24 +13101,32 @@ popmotion@11.0.3: ...@@ -13035,24 +13101,32 @@ popmotion@11.0.3:
style-value-types "5.0.0" style-value-types "5.0.0"
tslib "^2.1.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: postcss-modules-extract-imports@^3.0.0:
version "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" resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d"
integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==
postcss-modules-local-by-default@^4.0.0: postcss-modules-local-by-default@^4.0.0:
version "4.0.0" version "4.0.4"
resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.4.tgz#7cbed92abd312b94aaea85b68226d3dec39a14e6"
integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== integrity sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==
dependencies: dependencies:
icss-utils "^5.0.0" icss-utils "^5.0.0"
postcss-selector-parser "^6.0.2" postcss-selector-parser "^6.0.2"
postcss-value-parser "^4.1.0" postcss-value-parser "^4.1.0"
postcss-modules-scope@^3.0.0: postcss-modules-scope@^3.0.0:
version "3.0.0" version "3.1.1"
resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.1.1.tgz#32cfab55e84887c079a19bbb215e721d683ef134"
integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== integrity sha512-uZgqzdTleelWjzJY+Fhti6F3C9iF1JR/dODLs/JDefozYcKTBCdD8BIl6nNPbTbcLnGrk56hzwZC2DaGNvYjzA==
dependencies: dependencies:
postcss-selector-parser "^6.0.4" postcss-selector-parser "^6.0.4"
...@@ -13063,10 +13137,24 @@ postcss-modules-values@^4.0.0: ...@@ -13063,10 +13137,24 @@ postcss-modules-values@^4.0.0:
dependencies: dependencies:
icss-utils "^5.0.0" 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: postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4:
version "6.0.11" version "6.0.15"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz#2e41dc39b7ad74046e1615185185cd0b17d0c8dc" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz#11cc2b21eebc0b99ea374ffb9887174855a01535"
integrity sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g== integrity sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==
dependencies: dependencies:
cssesc "^3.0.0" cssesc "^3.0.0"
util-deprecate "^1.0.2" util-deprecate "^1.0.2"
...@@ -14389,7 +14477,7 @@ string-argv@^0.3.1: ...@@ -14389,7 +14477,7 @@ string-argv@^0.3.1:
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==
string-hash@^1.1.3: string-hash@^1.1.1, string-hash@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b"
integrity sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A== integrity sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==
...@@ -14547,6 +14635,11 @@ strip-json-comments@~2.0.1: ...@@ -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" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== 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: style-loader@^3.3.1:
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.1.tgz#057dfa6b3d4d7c7064462830f9113ed417d38575" 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: ...@@ -15021,6 +15114,11 @@ tslib@^2.3.0, tslib@^2.5.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== 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: tsutils@^3.21.0:
version "3.21.0" version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
...@@ -15721,9 +15819,9 @@ yaml@^1.10.0: ...@@ -15721,9 +15819,9 @@ yaml@^1.10.0:
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.1.1: yaml@^2.1.1:
version "2.1.3" version "2.4.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.1.3.tgz#9b3a4c8aff9821b696275c79a8bee8399d945207" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed"
integrity sha512-AacA8nRULjKMX2DvWvOAdBZMOfQlypSFkjcOcu9FalllIDJ1kvlREzcdIZmidQUqqeMv7jorHjq2HlLv/+c2lg== integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==
yaml@^2.2.2: yaml@^2.2.2:
version "2.3.1" 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