Commit 3d8e3986 authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into category-tabs

parents b352e18e 878cb938
{ {
"name": "blockscout dev", "name": "blockscout dev",
"image": "mcr.microsoft.com/devcontainers/typescript-node:18", "image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"forwardPorts": [ 3000 ], "forwardPorts": [ 3000 ],
"customizations": { "customizations": {
"vscode": { "vscode": {
......
...@@ -25,16 +25,16 @@ jobs: ...@@ -25,16 +25,16 @@ jobs:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip checks') && !(github.event.action == 'unlabeled' && github.event.label.name != 'skip checks') }} if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip checks') && !(github.event.action == 'unlabeled' && github.event.label.name != 'skip checks') }}
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup node - name: Setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20.11.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v3 uses: actions/cache@v4
id: cache-node-modules id: cache-node-modules
with: with:
path: | path: |
...@@ -57,16 +57,16 @@ jobs: ...@@ -57,16 +57,16 @@ jobs:
needs: [ code_quality ] needs: [ code_quality ]
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup node - name: Setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20.11.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v3 uses: actions/cache@v4
id: cache-node-modules id: cache-node-modules
with: with:
path: | path: |
...@@ -94,16 +94,16 @@ jobs: ...@@ -94,16 +94,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup node - name: Setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20.11.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v3 uses: actions/cache@v4
id: cache-node-modules id: cache-node-modules
with: with:
path: | path: |
...@@ -122,7 +122,7 @@ jobs: ...@@ -122,7 +122,7 @@ jobs:
needs: [ code_quality, envs_validation ] needs: [ code_quality, envs_validation ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.35.1-focal image: mcr.microsoft.com/playwright:v1.41.1-focal
strategy: strategy:
fail-fast: false fail-fast: false
...@@ -134,18 +134,18 @@ jobs: ...@@ -134,18 +134,18 @@ jobs:
run: apt-get update && apt-get install git-lfs run: apt-get update && apt-get install git-lfs
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
lfs: 'true' lfs: 'true'
- name: Setup node - name: Setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20.11.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v3 uses: actions/cache@v4
id: cache-node-modules id: cache-node-modules
with: with:
path: | path: |
...@@ -164,7 +164,7 @@ jobs: ...@@ -164,7 +164,7 @@ jobs:
- name: Upload test results - name: Upload test results
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: playwright-report-${{ matrix.project }} name: playwright-report-${{ matrix.project }}
path: playwright-report path: playwright-report
......
...@@ -16,16 +16,16 @@ jobs: ...@@ -16,16 +16,16 @@ jobs:
if: ${{ github.ref_type == 'tag' }} if: ${{ github.ref_type == 'tag' }}
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup node - name: Setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 20.11.0
cache: 'yarn' cache: 'yarn'
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v3 uses: actions/cache@v4
id: cache-node-modules id: cache-node-modules
with: with:
path: | path: |
......
# ***************************** # *****************************
# *** STAGE 1: Dependencies *** # *** STAGE 1: Dependencies ***
# ***************************** # *****************************
FROM node:18-alpine AS deps FROM node:20.11.0-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
...@@ -10,7 +10,7 @@ RUN apk add --no-cache libc6-compat ...@@ -10,7 +10,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN apk add git RUN apk add git
RUN yarn --frozen-lockfile --ignore-optional RUN yarn --frozen-lockfile
### FEATURE REPORTER ### FEATURE REPORTER
...@@ -30,7 +30,7 @@ RUN yarn --frozen-lockfile ...@@ -30,7 +30,7 @@ RUN yarn --frozen-lockfile
# ***************************** # *****************************
# ****** STAGE 2: Build ******* # ****** STAGE 2: Build *******
# ***************************** # *****************************
FROM node:18-alpine AS builder FROM node:20.11.0-alpine AS builder
RUN apk add --no-cache --upgrade libc6-compat bash RUN apk add --no-cache --upgrade libc6-compat bash
# pass commit sha and git tag to the app image # pass commit sha and git tag to the app image
...@@ -78,7 +78,7 @@ RUN cd ./deploy/tools/envs-validator && yarn build ...@@ -78,7 +78,7 @@ RUN cd ./deploy/tools/envs-validator && yarn build
# ******* STAGE 3: Run ******** # ******* STAGE 3: Run ********
# ***************************** # *****************************
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM node:18-alpine AS runner FROM node:20.11.0-alpine AS runner
RUN apk add --no-cache --upgrade bash curl jq unzip RUN apk add --no-cache --upgrade bash curl jq unzip
### APP ### APP
......
...@@ -8,6 +8,7 @@ const chain = Object.freeze({ ...@@ -8,6 +8,7 @@ const chain = Object.freeze({
shortName: getEnvValue('NEXT_PUBLIC_NETWORK_SHORT_NAME'), shortName: getEnvValue('NEXT_PUBLIC_NETWORK_SHORT_NAME'),
currency: { currency: {
name: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_NAME'), name: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_NAME'),
weiName: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_WEI_NAME'),
symbol: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL'), symbol: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL'),
decimals: Number(getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS')) || DEFAULT_CURRENCY_DECIMALS, decimals: Number(getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS')) || DEFAULT_CURRENCY_DECIMALS,
}, },
......
...@@ -20,6 +20,7 @@ export { default as sol2uml } from './sol2uml'; ...@@ -20,6 +20,7 @@ export { default as sol2uml } from './sol2uml';
export { default as stats } from './stats'; export { default as stats } from './stats';
export { default as suave } from './suave'; export { default as suave } from './suave';
export { default as txInterpretation } from './txInterpretation'; export { default as txInterpretation } from './txInterpretation';
export { default as web3Wallet } from './web3Wallet'; export { default as userOps } from './userOps';
export { default as verifiedTokens } from './verifiedTokens'; export { default as verifiedTokens } from './verifiedTokens';
export { default as web3Wallet } from './web3Wallet';
export { default as zkEvmRollup } from './zkEvmRollup'; export { default as zkEvmRollup } from './zkEvmRollup';
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const title = 'User operations';
const config: Feature<{ isEnabled: true }> = (() => {
if (getEnvValue('NEXT_PUBLIC_HAS_USER_OPS') === 'true') {
return Object.freeze({
title,
isEnabled: true,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
import type { ContractCodeIde } from 'types/client/contract';
import { NAVIGATION_LINK_IDS, type NavItemExternal, type NavigationLinkId } from 'types/client/navigation-items'; import { NAVIGATION_LINK_IDS, type NavItemExternal, type NavigationLinkId } from 'types/client/navigation-items';
import type { ChainIndicatorId } from 'types/homepage'; import type { ChainIndicatorId } from 'types/homepage';
import type { NetworkExplorer } from 'types/networks'; import type { NetworkExplorer } from 'types/networks';
...@@ -66,6 +67,9 @@ const UI = Object.freeze({ ...@@ -66,6 +67,9 @@ const UI = Object.freeze({
explorers: { explorers: {
items: parseEnvJson<Array<NetworkExplorer>>(getEnvValue('NEXT_PUBLIC_NETWORK_EXPLORERS')) || [], items: parseEnvJson<Array<NetworkExplorer>>(getEnvValue('NEXT_PUBLIC_NETWORK_EXPLORERS')) || [],
}, },
ides: {
items: parseEnvJson<Array<ContractCodeIde>>(getEnvValue('NEXT_PUBLIC_CONTRACT_CODE_IDES')) || [],
},
}); });
export default UI; export default UI;
...@@ -49,6 +49,9 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com ...@@ -49,6 +49,9 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens-rs-test.k8s-dev.blockscout.com NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens-rs-test.k8s-dev.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_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'}]
#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
...@@ -33,6 +33,7 @@ NEXT_PUBLIC_GIT_TAG=v1.0.11 ...@@ -33,6 +33,7 @@ NEXT_PUBLIC_GIT_TAG=v1.0.11
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'http://localhost:3000/nft-marketplace-logo.png'},{'name':'LooksRare','collection_url':'https://looksrare.org/collections/{hash}','instance_url':'https://looksrare.org/collections/{hash}/{id}','logo_url':'http://localhost:3000/nft-marketplace-logo.png'}] NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://opensea.io/assets/ethereum/{hash}','instance_url':'https://opensea.io/assets/ethereum/{hash}/{id}','logo_url':'http://localhost:3000/nft-marketplace-logo.png'},{'name':'LooksRare','collection_url':'https://looksrare.org/collections/{hash}','instance_url':'https://looksrare.org/collections/{hash}/{id}','logo_url':'http://localhost:3000/nft-marketplace-logo.png'}]
## misc ## misc
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}] NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Bitquery','baseUrl':'https://explorer.bitquery.io/','paths':{'tx':'/goerli/tx','address':'/goerli/address','token':'/goerli/token','block':'/goerli/block'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address','token':'/token','block':'/block'}}]
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.blockscout.com/%23address={hash}&blockscout=eth-goerli.blockscout.com'}]
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE= NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE=
# app features # app features
......
...@@ -11,6 +11,7 @@ import * as yup from 'yup'; ...@@ -11,6 +11,7 @@ import * as yup from 'yup';
import type { AdButlerConfig } from '../../../types/client/adButlerConfig'; import type { AdButlerConfig } from '../../../types/client/adButlerConfig';
import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS } from '../../../types/client/adProviders'; import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS } from '../../../types/client/adProviders';
import type { AdTextProviders, AdBannerProviders } from '../../../types/client/adProviders'; import type { AdTextProviders, AdBannerProviders } from '../../../types/client/adProviders';
import type { ContractCodeIde } from '../../../types/client/contract';
import type { MarketplaceAppOverview } from '../../../types/client/marketplace'; import type { MarketplaceAppOverview } from '../../../types/client/marketplace';
import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items'; import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items';
import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items'; import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items';
...@@ -264,6 +265,13 @@ const networkExplorerSchema: yup.ObjectSchema<NetworkExplorer> = yup ...@@ -264,6 +265,13 @@ const networkExplorerSchema: yup.ObjectSchema<NetworkExplorer> = yup
}), }),
}); });
const contractCodeIdeSchema: yup.ObjectSchema<ContractCodeIde> = yup
.object({
title: yup.string().required(),
url: yup.string().test(urlTest).required(),
icon_url: yup.string().test(urlTest).required(),
});
const nftMarketplaceSchema: yup.ObjectSchema<NftMarketplaceItem> = yup const nftMarketplaceSchema: yup.ObjectSchema<NftMarketplaceItem> = yup
.object({ .object({
name: yup.string().required(), name: yup.string().required(),
...@@ -331,6 +339,7 @@ const schema = yup ...@@ -331,6 +339,7 @@ const schema = yup
NEXT_PUBLIC_NETWORK_ID: yup.number().positive().integer().required(), NEXT_PUBLIC_NETWORK_ID: yup.number().positive().integer().required(),
NEXT_PUBLIC_NETWORK_RPC_URL: yup.string().test(urlTest), NEXT_PUBLIC_NETWORK_RPC_URL: yup.string().test(urlTest),
NEXT_PUBLIC_NETWORK_CURRENCY_NAME: yup.string(), NEXT_PUBLIC_NETWORK_CURRENCY_NAME: yup.string(),
NEXT_PUBLIC_NETWORK_CURRENCY_WEI_NAME: yup.string(),
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL: yup.string(), NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL: yup.string(),
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS: yup.number().integer().positive(), NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS: yup.number().integer().positive(),
NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL: yup.string(), NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL: yup.string(),
...@@ -417,6 +426,11 @@ const schema = yup ...@@ -417,6 +426,11 @@ const schema = yup
.transform(replaceQuotes) .transform(replaceQuotes)
.json() .json()
.of(networkExplorerSchema), .of(networkExplorerSchema),
NEXT_PUBLIC_CONTRACT_CODE_IDES: yup
.array()
.transform(replaceQuotes)
.json()
.of(contractCodeIdeSchema),
NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS: yup.boolean(), NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS: yup.boolean(),
NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS: yup.boolean(), NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS: yup.boolean(),
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE: yup.string(), NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE: yup.string(),
...@@ -447,6 +461,7 @@ const schema = yup ...@@ -447,6 +461,7 @@ const schema = yup
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest), NEXT_PUBLIC_OG_IMAGE_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(),
// 6. External services envs // 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
......
...@@ -9,6 +9,7 @@ NEXT_PUBLIC_APP_PORT=3000 ...@@ -9,6 +9,7 @@ NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_PROTOCOL=http NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS=[{'id':'1','title':'Ethereum','short_title':'ETH','base_url':'https://example.com'}] NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS=[{'id':'1','title':'Ethereum','short_title':'ETH','base_url':'https://example.com'}]
NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES=[{'type':'omni','title':'OmniBridge','short_title':'OMNI'}] NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES=[{'type':'omni','title':'OmniBridge','short_title':'OMNI'}]
NEXT_PUBLIC_CONTRACT_CODE_IDES=[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout={domain}','icon_url':'https://example.com/icon.svg'}]
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://example.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://example.com
NEXT_PUBLIC_FEATURED_NETWORKS=https://example.com NEXT_PUBLIC_FEATURED_NETWORKS=https://example.com
NEXT_PUBLIC_FOOTER_LINKS=https://example.com NEXT_PUBLIC_FOOTER_LINKS=https://example.com
......
...@@ -161,6 +161,7 @@ frontend: ...@@ -161,6 +161,7 @@ frontend:
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']" NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]" NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
envFromSecret: envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/front-main?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......
...@@ -73,6 +73,8 @@ frontend: ...@@ -73,6 +73,8 @@ frontend:
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']" NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']"
NEXT_PUBLIC_USE_NEXT_JS_PROXY: true NEXT_PUBLIC_USE_NEXT_JS_PROXY: true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]" NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_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'}]"
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
......
...@@ -14,7 +14,7 @@ Thanks for showing interest to contribute to Blockscout. The following steps wil ...@@ -14,7 +14,7 @@ Thanks for showing interest to contribute to Blockscout. The following steps wil
cd <fork_name> cd <fork_name>
``` ```
3. Make sure you're running Node.js 18+ and NPM 8+; if not, upgrade it accordingly, for example using [nvm](https://github.com/nvm-sh/nvm). 3. Make sure you're running Node.js 20+ and NPM 10+; if not, upgrade it accordingly, for example using [nvm](https://github.com/nvm-sh/nvm).
```sh ```sh
node -v node -v
npm -v npm -v
......
...@@ -33,6 +33,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -33,6 +33,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Banner ads](ENVS.md#banner-ads) - [Banner ads](ENVS.md#banner-ads)
- [Text ads](ENVS.md#text-ads) - [Text ads](ENVS.md#text-ads)
- [Beacon chain](ENVS.md#beacon-chain) - [Beacon chain](ENVS.md#beacon-chain)
- [User operations](ENVS.md#user-operations-feature-erc-4337)
- [Optimistic rollup (L2) chain](ENVS.md#optimistic-rollup-l2-chain) - [Optimistic rollup (L2) chain](ENVS.md#optimistic-rollup-l2-chain)
- [ZkEvm rollup (L2) chain](NVS.md#zkevm-rollup-l2-chain) - [ZkEvm rollup (L2) chain](NVS.md#zkevm-rollup-l2-chain)
- [Export data to CSV file](ENVS.md#export-data-to-csv-file) - [Export data to CSV file](ENVS.md#export-data-to-csv-file)
...@@ -77,6 +78,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -77,6 +78,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
| NEXT_PUBLIC_NETWORK_ID | `number` | Chain id, see [https://chainlist.org](https://chainlist.org) for the reference | Required | - | `99` | | NEXT_PUBLIC_NETWORK_ID | `number` | Chain id, see [https://chainlist.org](https://chainlist.org) for the reference | Required | - | `99` |
| NEXT_PUBLIC_NETWORK_RPC_URL | `string` | Chain public RPC server url, see [https://chainlist.org](https://chainlist.org) for the reference | - | - | `https://core.poa.network` | | NEXT_PUBLIC_NETWORK_RPC_URL | `string` | Chain public RPC server url, see [https://chainlist.org](https://chainlist.org) for the reference | - | - | `https://core.poa.network` |
| NEXT_PUBLIC_NETWORK_CURRENCY_NAME | `string` | Network currency name | - | - | `Ether` | | NEXT_PUBLIC_NETWORK_CURRENCY_NAME | `string` | Network currency name | - | - | `Ether` |
| NEXT_PUBLIC_NETWORK_CURRENCY_WEI_NAME | `string` | Name of network currency subdenomination | - | `wei` | `duck` |
| NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL | `string` | Network currency symbol | - | - | `ETH` | | NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL | `string` | Network currency symbol | - | - | `ETH` |
| NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS | `string` | Network currency decimals | - | `18` | `6` | | NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS | `string` | Network currency decimals | - | `18` | `6` |
| NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL | `string` | Network governance token symbol | - | - | `GNO` | | NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL | `string` | Network governance token symbol | - | - | `GNO` |
...@@ -258,6 +260,7 @@ Settings for meta tags and OG tags ...@@ -258,6 +260,7 @@ Settings for meta tags and OG tags
| Variable | Type| Description | Compulsoriness | Default value | Example value | | Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_NETWORK_EXPLORERS | `Array<NetworkExplorer>` where `NetworkExplorer` can have following [properties](#network-explorer-configuration-properties) | Used to build up links to transactions, blocks, addresses in other chain explorers. | - | - | `[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/tx'}}]` | | NEXT_PUBLIC_NETWORK_EXPLORERS | `Array<NetworkExplorer>` where `NetworkExplorer` can have following [properties](#network-explorer-configuration-properties) | Used to build up links to transactions, blocks, addresses in other chain explorers. | - | - | `[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/poa/core/tx'}}]` |
| NEXT_PUBLIC_CONTRACT_CODE_IDES | `Array<ContractCodeIde>` where `ContractCodeIde` can have following [properties](#contract-code-ide-configuration-properties) | Used to build up links to IDEs with contract source code. | - | - | `[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout={domain}','icon_url':'https://example.com/icon.svg'}]` |
| NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS | `boolean` | Set to `true` to hide indexing alert in the page header about indexing chain's blocks | - | `false` | `true` | | NEXT_PUBLIC_HIDE_INDEXING_ALERT_BLOCKS | `boolean` | Set to `true` to hide indexing alert in the page header about indexing chain's blocks | - | `false` | `true` |
| NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS | `boolean` | Set to `true` to hide indexing alert in the page footer about indexing block's internal transactions | - | `false` | `true` | | NEXT_PUBLIC_HIDE_INDEXING_ALERT_INT_TXS | `boolean` | Set to `true` to hide indexing alert in the page footer about indexing block's internal transactions | - | `false` | `true` |
| NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE | `string` | Used for displaying custom announcements or alerts in the header of the site. Could be a regular string or a HTML code. | - | - | `Hello world! 🤪` | | NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE | `string` | Used for displaying custom announcements or alerts in the header of the site. Could be a regular string or a HTML code. | - | - | `Hello world! 🤪` |
...@@ -272,6 +275,14 @@ Settings for meta tags and OG tags ...@@ -272,6 +275,14 @@ Settings for meta tags and OG tags
*Note* The url of an entity will be constructed as `<baseUrl><paths[<entity-type>]><entity-id>`, e.g `https://explorer.anyblock.tools/ethereum/poa/core/tx/<tx-id>` *Note* The url of an entity will be constructed as `<baseUrl><paths[<entity-type>]><entity-id>`, e.g `https://explorer.anyblock.tools/ethereum/poa/core/tx/<tx-id>`
#### Contract code IDE configuration properties
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| title | `string` | Displayed name of the IDE | Required | - | `Remix IDE` |
| url | `string` | URL of the IDE with placeholders for contract hash (`{hash}`) and current domain (`{domain}`) | Required | - | `https://remix.blockscout.com/?address={hash}&blockscout={domain}` |
| icon_url | `string` | URL of the IDE icon | Required | - | `https://example.com/icon.svg` |
&nbsp; &nbsp;
## App features ## App features
...@@ -345,6 +356,14 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi ...@@ -345,6 +356,14 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
&nbsp; &nbsp;
### User operations feature (ERC-4337)
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_HAS_USER_OPS | `boolean` | Set to true to show user operations related data and pages | - | - | `true` |
&nbsp;
### Optimistic rollup (L2) chain ### Optimistic rollup (L2) chain
| Variable | Type| Description | Compulsoriness | Default value | Example value | | Variable | Type| Description | Compulsoriness | Default value | Example value |
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<g clip-path="url(#a)" fill="currentColor">
<path d="M5.176 5.291a1.253 1.253 0 0 0-1.76.159L.29 9.2a1.247 1.247 0 0 0 0 1.6l3.125 3.75a1.25 1.25 0 1 0 1.919-1.6L2.876 10l2.459-2.949a1.25 1.25 0 0 0-.159-1.76Z"/>
<path d="M5.176 5.291a1.253 1.253 0 0 0-1.76.159L.29 9.2a1.247 1.247 0 0 0 0 1.6l3.125 3.75a1.25 1.25 0 1 0 1.919-1.6L2.876 10l2.459-2.949a1.25 1.25 0 0 0-.159-1.76Zm6.318-2.766a1.261 1.261 0 0 0-1.47.981l-2.5 12.5a1.247 1.247 0 0 0 1.23 1.494 1.249 1.249 0 0 0 1.223-1.006l2.5-12.5a1.246 1.246 0 0 0-.982-1.469Z"/>
<path d="M11.494 2.525a1.261 1.261 0 0 0-1.47.981l-2.5 12.5a1.247 1.247 0 0 0 1.23 1.494 1.249 1.249 0 0 0 1.223-1.006l2.5-12.5a1.246 1.246 0 0 0-.982-1.469ZM19.708 9.2l-3.124-3.75a1.249 1.249 0 1 0-1.92 1.601l2.46 2.95-2.46 2.948a1.25 1.25 0 1 0 1.92 1.602l3.124-3.75a1.246 1.246 0 0 0 0-1.601Z"/>
<path d="m19.708 9.2-3.124-3.75a1.249 1.249 0 1 0-1.92 1.601l2.46 2.95-2.46 2.948a1.25 1.25 0 1 0 1.92 1.602l3.124-3.75a1.246 1.246 0 0 0 0-1.601Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 18 4" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="2" cy="2" r="2" fill="currentColor"/>
<circle cx="9" cy="2" r="2" fill="currentColor"/>
<circle cx="16" cy="2" r="2" fill="currentColor"/>
</svg>
<svg viewBox="-1 -1 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="-1 -1 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.24.661a.964.964 0 1 0-1.831.607l.426 1.285a.964.964 0 0 0 1.83-.606L7.24.661Zm10.478.985A.964.964 0 0 0 16.354.283l-3.495 3.495-.204-.204a2.893 2.893 0 0 0-4.091 0L6.75 5.388A.964.964 0 0 0 5.388 6.75L3.575 8.562a2.893 2.893 0 0 0 0 4.091l.204.204-3.496 3.497a.964.964 0 1 0 1.364 1.363l3.496-3.496.205.205a2.893 2.893 0 0 0 4.091 0l1.813-1.813a.964.964 0 0 0 1.361-1.361l1.815-1.814a2.893 2.893 0 0 0 0-4.091l-.206-.206 3.496-3.495ZM11.25 9.887l1.813-1.813a.964.964 0 0 0 0-1.364l-.839-.839a.996.996 0 0 1-.096-.096l-.838-.838a.964.964 0 0 0-1.363 0L8.114 6.751l3.137 3.136Zm-4.5-1.773L4.939 9.926a.964.964 0 0 0 0 1.364l.864.864a1.193 1.193 0 0 1 .044.043l.865.866a.964.964 0 0 0 1.363 0l1.812-1.812-3.136-3.137Zm4.196 6.72a.964.964 0 0 1 1.218.613l.426 1.285a.964.964 0 1 1-1.83.607l-.426-1.286a.964.964 0 0 1 .612-1.219Zm3.888-3.887a.964.964 0 0 1 1.218-.612l1.286.425a.964.964 0 1 1-.607 1.831l-1.285-.426a.964.964 0 0 1-.612-1.218ZM1.268 5.409a.964.964 0 1 0-.607 1.83l1.286.426a.964.964 0 1 0 .606-1.83l-1.285-.426Z" fill="#38A169"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M7.24.661a.964.964 0 1 0-1.831.607l.426 1.285a.964.964 0 0 0 1.83-.606L7.24.661Zm10.478.985A.964.964 0 0 0 16.354.283l-3.495 3.495-.204-.204a2.893 2.893 0 0 0-4.091 0L6.75 5.388A.964.964 0 0 0 5.388 6.75L3.575 8.562a2.893 2.893 0 0 0 0 4.091l.204.204-3.496 3.497a.964.964 0 1 0 1.364 1.363l3.496-3.496.205.205a2.893 2.893 0 0 0 4.091 0l1.813-1.813a.964.964 0 0 0 1.361-1.361l1.815-1.814a2.893 2.893 0 0 0 0-4.091l-.206-.206 3.496-3.495ZM11.25 9.887l1.813-1.813a.964.964 0 0 0 0-1.364l-.839-.839a.996.996 0 0 1-.096-.096l-.838-.838a.964.964 0 0 0-1.363 0L8.114 6.751l3.137 3.136Zm-4.5-1.773L4.939 9.926a.964.964 0 0 0 0 1.364l.864.864a1.193 1.193 0 0 1 .044.043l.865.866a.964.964 0 0 0 1.363 0l1.812-1.812-3.136-3.137Zm4.196 6.72a.964.964 0 0 1 1.218.613l.426 1.285a.964.964 0 1 1-1.83.607l-.426-1.286a.964.964 0 0 1 .612-1.219Zm3.888-3.887a.964.964 0 0 1 1.218-.612l1.286.425a.964.964 0 1 1-.607 1.831l-1.285-.426a.964.964 0 0 1-.612-1.218ZM1.268 5.409a.964.964 0 1 0-.607 1.83l1.286.426a.964.964 0 1 0 .606-1.83l-1.285-.426Z" fill="currentColor"/>
</svg> </svg>
<svg viewBox="0 -1 19 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 -1 19 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.394.273a.931.931 0 0 1 0 1.316l-1.825 1.825.194.194h.001a2.795 2.795 0 0 1 0 3.956l-1.747 1.747a.931.931 0 0 1-1.354 1.278l-.609-.61-.011-.01-.012-.012L8.71 5.635l-.011-.011-.011-.012-.61-.609A.931.931 0 0 1 9.355 3.65l1.747-1.747a2.794 2.794 0 0 1 3.956 0l.195.194L17.077.273a.931.931 0 0 1 1.317 0Zm-2.945 5.973L13.7 7.994l-3.028-3.029 1.748-1.747a.93.93 0 0 1 1.319 0l1.708 1.708a.93.93 0 0 1 0 1.32ZM6.563 8.304l-.893-.893a.931.931 0 0 0-1.354 1.278L2.57 10.435l-.001.001a2.793 2.793 0 0 0 0 3.956l.195.194-1.825 1.825a.931.931 0 0 0 1.317 1.316l1.824-1.824.194.194a2.793 2.793 0 0 0 3.956 0h.001l1.747-1.747a.931.931 0 0 0 1.278-1.353l-.894-.893.894-.894a.931.931 0 0 0-1.317-1.317l-.893.894L7.88 9.62l.893-.894a.931.931 0 1 0-1.317-1.316l-.893.893Zm-.655 1.978-.003-.003-.004-.003-.27-.27-1.747 1.748a.931.931 0 0 0 0 1.32l.845.844.01.01.01.01.843.843.001.002a.93.93 0 0 0 1.319 0l1.748-1.748-2.752-2.753Z" fill="#A0AEC0"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M18.394.273a.931.931 0 0 1 0 1.316l-1.825 1.825.194.194h.001a2.795 2.795 0 0 1 0 3.956l-1.747 1.747a.931.931 0 0 1-1.354 1.278l-.609-.61-.011-.01-.012-.012L8.71 5.635l-.011-.011-.011-.012-.61-.609A.931.931 0 0 1 9.355 3.65l1.747-1.747a2.794 2.794 0 0 1 3.956 0l.195.194L17.077.273a.931.931 0 0 1 1.317 0Zm-2.945 5.973L13.7 7.994l-3.028-3.029 1.748-1.747a.93.93 0 0 1 1.319 0l1.708 1.708a.93.93 0 0 1 0 1.32ZM6.563 8.304l-.893-.893a.931.931 0 0 0-1.354 1.278L2.57 10.435l-.001.001a2.793 2.793 0 0 0 0 3.956l.195.194-1.825 1.825a.931.931 0 0 0 1.317 1.316l1.824-1.824.194.194a2.793 2.793 0 0 0 3.956 0h.001l1.747-1.747a.931.931 0 0 0 1.278-1.353l-.894-.893.894-.894a.931.931 0 0 0-1.317-1.317l-.893.894L7.88 9.62l.893-.894A.931.931 0 1 0 7.456 7.41l-.893.893Zm-.655 1.978-.003-.003-.004-.003-.27-.27-1.747 1.748a.931.931 0 0 0 0 1.32l.845.844.01.01.01.01.843.843.001.002a.93.93 0 0 0 1.319 0l1.748-1.748-2.752-2.753Z" fill="currentColor"/>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30">
<path fill="currentColor" d="M22.576 11.994a1.212 1.212 0 0 0 0-2.424H9.14L10.703 8a1.212 1.212 0 0 0-1.709-1.709L5.358 9.928a1.212 1.212 0 0 0-.267 1.32 1.212 1.212 0 0 0 1.121.746h16.364Z"/>
<path fill="currentColor" fill-rule="evenodd" d="M15.859 18.661c.091-.363.14-.754.14-1.161 0-.445-.059-.871-.167-1.263h7.955a1.213 1.213 0 0 1 1.121.745 1.212 1.212 0 0 1-.267 1.321l-3.636 3.637a1.214 1.214 0 0 1-2.049-.347 1.212 1.212 0 0 1 .34-1.362l1.564-1.57h-5.001Zm-3.633 1.862a3.636 3.636 0 0 0 1.275-1.83 3.615 3.615 0 0 0-.035-2.225 3.638 3.638 0 0 0-1.334-1.787 3.675 3.675 0 0 0-4.264 0 3.638 3.638 0 0 0-1.333 1.787 3.615 3.615 0 0 0-.036 2.226 3.636 3.636 0 0 0 1.275 1.829 5.482 5.482 0 0 0-2.714 2.606.588.588 0 0 0 .038.583.593.593 0 0 0 .51.288h1.413C7.2 22.76 8.465 21.8 10 21.8c1.535 0 2.8.96 2.979 2.2h1.412a.599.599 0 0 0 .511-.288.588.588 0 0 0 .038-.583 5.482 5.482 0 0 0-2.714-2.606ZM11.7 17.5a1.7 1.7 0 1 1-3.4 0 1.7 1.7 0 0 1 3.4 0Z" clip-rule="evenodd"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 21">
<path d="m.29 5.053 3.722-3.828a.972.972 0 0 1 1.337.06c.177.183.283.429.292.69.01.257-.074.509-.234.704L3.399 4.751H17.77c.262 0 .514.107.7.3a1.05 1.05 0 0 1 0 1.459.976.976 0 0 1-.7.298H.987a.969.969 0 0 1-.55-.17 1.021 1.021 0 0 1-.368-.46 1.062 1.062 0 0 1 .22-1.125zm18.723 6a.97.97 0 0 1 .55.17c.163.111.292.271.368.46a1.061 1.061 0 0 1-.22 1.125l-3.737 3.841-.006.008a.997.997 0 0 1-.322.255.966.966 0 0 1-1.13-.197c-.097-.1-.174-.22-.224-.352a1.067 1.067 0 0 1 .03-.828c.06-.128.146-.241.25-.333l.008-.006 2.02-2.087h-5.88c.103-.684.183-1.403 0-2.072zM8.501 13.694a3.636 3.636 0 0 1-1.275 1.829 5.482 5.482 0 0 1 2.714 2.606.588.588 0 0 1-.038.583.593.593 0 0 1-.51.288H7.978C7.8 17.76 6.535 16.8 5 16.8s-2.8.96-2.979 2.2H.61a.599.599 0 0 1-.511-.288.588.588 0 0 1-.038-.583 5.482 5.482 0 0 1 2.714-2.606 3.636 3.636 0 0 1-1.275-1.83 3.615 3.615 0 0 1 .036-2.224A3.638 3.638 0 0 1 2.868 9.68a3.675 3.675 0 0 1 4.264 0 3.638 3.638 0 0 1 1.333 1.788c.246.72.259 1.497.036 2.225zM6.7 12.5a1.7 1.7 0 1 1-3.4 0 1.7 1.7 0 0 1 3.4 0z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"/>
</svg>
<svg viewBox="2 2 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.667 3.75c0-.688-.563-1.25-1.25-1.25-.688 0-1.25.563-1.25 1.25 0 .688.562 1.25 1.25 1.25.687 0 1.25-.563 1.25-1.25Zm0 12.5c0-.688-.563-1.25-1.25-1.25-.688 0-1.25.563-1.25 1.25 0 .688.562 1.25 1.25 1.25.687 0 1.25-.563 1.25-1.25Zm0-6.25c0-.688-.563-1.25-1.25-1.25-.688 0-1.25.563-1.25 1.25 0 .688.562 1.25 1.25 1.25.687 0 1.25-.563 1.25-1.25Z" fill="currentColor" fill-opacity=".8"/>
</svg>
...@@ -77,6 +77,7 @@ import type { ...@@ -77,6 +77,7 @@ import type {
import type { TxInterpretationResponse } from 'types/api/txInterpretation'; import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { TTxsFilters } from 'types/api/txsFilters'; import type { TTxsFilters } 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 { VerifiedContractsSorting } from 'types/api/verifiedContracts'; import type { VerifiedContractsSorting } from 'types/api/verifiedContracts';
import type { VisualizedContract } from 'types/api/visualization'; import type { VisualizedContract } from 'types/api/visualization';
import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals'; import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals';
...@@ -579,6 +580,20 @@ export const RESOURCES = { ...@@ -579,6 +580,20 @@ export const RESOURCES = {
filterFields: [], filterFields: [],
}, },
// USER OPS
user_ops: {
path: '/api/v2/proxy/account-abstraction/operations',
filterFields: [ 'transaction_hash' as const, 'sender' as const ],
},
user_op: {
path: '/api/v2/proxy/account-abstraction/operations/:hash',
pathParams: [ 'hash' as const ],
},
user_ops_account: {
path: '/api/v2/proxy/account-abstraction/accounts/: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',
...@@ -651,7 +666,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -651,7 +666,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' | 'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' |
'withdrawals' | 'address_withdrawals' | 'block_withdrawals' | 'withdrawals' | 'address_withdrawals' | 'block_withdrawals' |
'watchlist' | 'private_tags_address' | 'private_tags_tx' | 'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup'; 'domains_lookup' | 'addresses_lookup' | 'user_ops';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -754,6 +769,9 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse : ...@@ -754,6 +769,9 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed : Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse : Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse : Q extends 'domains_lookup' ? EnsDomainLookupResponse :
Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -775,6 +793,7 @@ Q extends 'tokens_bridged' ? TokensBridgedFilters : ...@@ -775,6 +793,7 @@ Q extends 'tokens_bridged' ? TokensBridgedFilters :
Q extends 'verified_contracts' ? VerifiedContractsFilters : Q extends 'verified_contracts' ? VerifiedContractsFilters :
Q extends 'addresses_lookup' ? EnsAddressLookupFilters : Q extends 'addresses_lookup' ? EnsAddressLookupFilters :
Q extends 'domains_lookup' ? EnsDomainLookupFilters : Q extends 'domains_lookup' ? EnsDomainLookupFilters :
Q extends 'user_ops' ? UserOpsFilters :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
......
...@@ -57,6 +57,7 @@ export default function useApiFetch() { ...@@ -57,6 +57,7 @@ export default function useApiFetch() {
}, },
{ {
resource: resource.path, resource: resource.path,
omitSentryErrorLog: true, // disable logging of API errors to Sentry
}, },
); );
}, [ fetch, csrfToken ]); }, [ fetch, csrfToken ]);
......
...@@ -4,20 +4,22 @@ import React from 'react'; ...@@ -4,20 +4,22 @@ import React from 'react';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload'; import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode'; import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
export const retry = (failureCount: number, error: unknown) => {
const errorPayload = getErrorObjPayload<{ status: number }>(error);
const status = errorPayload?.status || getErrorObjStatusCode(error);
if (status && status >= 400 && status < 500) {
// don't do retry for client error responses
return false;
}
return failureCount < 2;
};
export default function useQueryClientConfig() { export default function useQueryClientConfig() {
const [ queryClient ] = React.useState(() => new QueryClient({ const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: (failureCount, error) => { retry,
const errorPayload = getErrorObjPayload<{ status: number }>(error);
const status = errorPayload?.status || getErrorObjStatusCode(error);
if (status && status >= 400 && status < 500) {
// don't do retry for client error responses
return false;
}
return failureCount < 2;
},
throwOnError: (error) => { throwOnError: (error) => {
const status = getErrorObjStatusCode(error); const status = getErrorObjStatusCode(error);
// don't catch error for "Too many requests" response // don't catch error for "Too many requests" response
......
export default function throwOnAbsentParamError(param: unknown) {
if (!param) {
throw new Error('Required param not provided', { cause: { status: 404 } });
}
}
import type { ResourceError, ResourceName } from 'lib/api/resources';
type Params = ({
isError: true;
error: ResourceError<unknown>;
} | {
isError: false;
error: null;
}) & {
resource?: ResourceName;
}
export const RESOURCE_LOAD_ERROR_MESSAGE = 'Resource load error';
export default function throwOnResourceLoadError({ isError, error, resource }: Params) {
if (isError) {
throw Error(RESOURCE_LOAD_ERROR_MESSAGE, { cause: { ...error, resource } as unknown as Error });
}
}
export default function hetToDecimal(hex: string) {
const strippedHex = hex.startsWith('0x') ? hex.slice(2) : hex;
return parseInt(strippedHex, 16);
}
import _clamp from 'lodash/clamp';
import React from 'react';
import { useInView } from 'react-intersection-observer';
const STEP = 10;
const MIN_ITEMS_NUM = 50;
export default function useLazyRenderedList(list: Array<unknown>, isEnabled: boolean) {
const [ renderedItemsNum, setRenderedItemsNum ] = React.useState(MIN_ITEMS_NUM);
const { ref, inView } = useInView({
rootMargin: '200px',
triggerOnce: false,
skip: !isEnabled || list.length <= MIN_ITEMS_NUM,
});
React.useEffect(() => {
if (inView) {
setRenderedItemsNum((prev) => _clamp(prev + STEP, 0, list.length));
}
}, [ inView, list.length ]);
return { cutRef: ref, renderedItemsNum };
}
...@@ -46,6 +46,13 @@ export default function useNavItems(): ReturnType { ...@@ -46,6 +46,13 @@ export default function useNavItems(): ReturnType {
icon: 'transactions', icon: 'transactions',
isActive: pathname === '/txs' || pathname === '/tx/[hash]', isActive: pathname === '/txs' || pathname === '/tx/[hash]',
}; };
const userOps: NavItem | null = config.features.userOps.isEnabled ? {
text: 'User operations',
nextRoute: { pathname: '/ops' as const },
icon: 'user_op',
isActive: pathname === '/ops' || pathname === '/op/[hash]',
} : null;
const verifiedContracts: NavItem | null = const verifiedContracts: NavItem | null =
{ {
text: 'Verified contracts', text: 'Verified contracts',
...@@ -54,7 +61,7 @@ export default function useNavItems(): ReturnType { ...@@ -54,7 +61,7 @@ export default function useNavItems(): ReturnType {
isActive: pathname === '/verified-contracts', isActive: pathname === '/verified-contracts',
}; };
const ensLookup = config.features.nameService.isEnabled ? { const ensLookup = config.features.nameService.isEnabled ? {
text: 'ENS lookup', text: 'Name services lookup',
nextRoute: { pathname: '/name-domains' as const }, nextRoute: { pathname: '/name-domains' as const },
icon: 'ENS', icon: 'ENS',
isActive: pathname === '/name-domains' || pathname === '/name-domains/[name]', isActive: pathname === '/name-domains' || pathname === '/name-domains/[name]',
...@@ -64,6 +71,7 @@ export default function useNavItems(): ReturnType { ...@@ -64,6 +71,7 @@ export default function useNavItems(): ReturnType {
blockchainNavItems = [ blockchainNavItems = [
[ [
txs, txs,
userOps,
blocks, blocks,
{ {
text: 'Txn batches', text: 'Txn batches',
...@@ -71,7 +79,7 @@ export default function useNavItems(): ReturnType { ...@@ -71,7 +79,7 @@ export default function useNavItems(): ReturnType {
icon: 'txn_batches', icon: 'txn_batches',
isActive: pathname === '/zkevm-l2-txn-batches' || pathname === '/zkevm-l2-txn-batch/[number]', isActive: pathname === '/zkevm-l2-txn-batches' || pathname === '/zkevm-l2-txn-batch/[number]',
}, },
], ].filter(Boolean),
[ [
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
...@@ -95,6 +103,7 @@ export default function useNavItems(): ReturnType { ...@@ -95,6 +103,7 @@ export default function useNavItems(): ReturnType {
{ text: 'Output roots', nextRoute: { pathname: '/l2-output-roots' as const }, icon: 'output_roots', isActive: pathname === '/l2-output-roots' }, { text: 'Output roots', nextRoute: { pathname: '/l2-output-roots' as const }, icon: 'output_roots', isActive: pathname === '/l2-output-roots' },
], ],
[ [
userOps,
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
ensLookup, ensLookup,
...@@ -103,6 +112,7 @@ export default function useNavItems(): ReturnType { ...@@ -103,6 +112,7 @@ export default function useNavItems(): ReturnType {
} else { } else {
blockchainNavItems = [ blockchainNavItems = [
txs, txs,
userOps,
blocks, blocks,
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
......
...@@ -39,6 +39,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -39,6 +39,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/l2-withdrawals': 'Root page', '/l2-withdrawals': 'Root page',
'/zkevm-l2-txn-batches': 'Root page', '/zkevm-l2-txn-batches': 'Root page',
'/zkevm-l2-txn-batch/[number]': 'Regular page', '/zkevm-l2-txn-batch/[number]': 'Regular page',
'/ops': 'Root page',
'/op/[hash]': 'Regular page',
'/404': 'Regular page', '/404': 'Regular page',
'/name-domains': 'Root page', '/name-domains': 'Root page',
'/name-domains/[name]': 'Regular page', '/name-domains/[name]': 'Regular page',
......
...@@ -42,6 +42,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -42,6 +42,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/l2-withdrawals': DEFAULT_TEMPLATE, '/l2-withdrawals': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batches': DEFAULT_TEMPLATE, '/zkevm-l2-txn-batches': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE, '/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE,
'/ops': DEFAULT_TEMPLATE,
'/op/[hash]': DEFAULT_TEMPLATE,
'/404': DEFAULT_TEMPLATE, '/404': DEFAULT_TEMPLATE,
'/name-domains': DEFAULT_TEMPLATE, '/name-domains': DEFAULT_TEMPLATE,
'/name-domains/[name]': DEFAULT_TEMPLATE, '/name-domains/[name]': DEFAULT_TEMPLATE,
......
...@@ -37,6 +37,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -37,6 +37,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/l2-withdrawals': 'withdrawals (L2 > L1)', '/l2-withdrawals': 'withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches', '/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%', '/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%',
'/ops': 'user operations',
'/op/[hash]': 'user operation %hash%',
'/404': 'error - page not found', '/404': 'error - page not found',
'/name-domains': 'domains search and resolve', '/name-domains': 'domains search and resolve',
'/name-domains/[name]': '%name% domain details', '/name-domains/[name]': '%name% domain details',
......
...@@ -37,6 +37,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -37,6 +37,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/l2-withdrawals': 'Withdrawals (L2 > L1)', '/l2-withdrawals': 'Withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches', '/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details', '/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details',
'/ops': 'User operations',
'/op/[hash]': 'User operation details',
'/404': '404', '/404': '404',
'/name-domains': 'Domains search and resolve', '/name-domains': 'Domains search and resolve',
'/name-domains/[name]': 'Domain details', '/name-domains/[name]': 'Domain details',
......
...@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/react'; ...@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing'; import { BrowserTracing } from '@sentry/tracing';
import appConfig from 'configs/app'; import appConfig from 'configs/app';
import { RESOURCE_LOAD_ERROR_MESSAGE } from 'lib/errors/throwOnResourceLoadError';
const feature = appConfig.features.sentry; const feature = appConfig.features.sentry;
...@@ -59,6 +60,9 @@ export const config: Sentry.BrowserOptions | undefined = (() => { ...@@ -59,6 +60,9 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
'The quota has been exceeded', 'The quota has been exceeded',
'Attempt to connect to relay via', 'Attempt to connect to relay via',
'WebSocket connection failed for URL: wss://relay.walletconnect.com', 'WebSocket connection failed for URL: wss://relay.walletconnect.com',
// API errors
RESOURCE_LOAD_ERROR_MESSAGE,
], ],
denyUrls: [ denyUrls: [
// Facebook flakiness // Facebook flakiness
......
import type { Unit } from 'types/unit';
import config from 'configs/app';
const weiName = config.chain.currency.weiName || 'wei';
export const currencyUnits: Record<Unit, string> = {
wei: weiName,
gwei: `G${ weiName }`,
ether: config.chain.currency.symbol || 'ETH',
};
import { createPublicClient, http } from 'viem';
import currentChain from './currentChain';
export const publicClient = createPublicClient({
chain: currentChain,
transport: http(),
batch: {
multicall: true,
},
});
import type { Chain } from 'wagmi';
import config from 'configs/app';
const currentChain: Chain = {
id: Number(config.chain.id),
name: config.chain.name ?? '',
network: config.chain.name ?? '',
nativeCurrency: {
decimals: config.chain.currency.decimals,
name: config.chain.currency.name ?? '',
symbol: config.chain.currency.symbol ?? '',
},
rpcUrls: {
'public': {
http: [ config.chain.rpcUrl ?? '' ],
},
'default': {
http: [ config.chain.rpcUrl ?? '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: config.app.baseUrl,
},
},
};
export default currentChain;
import type { SearchResultToken, SearchResultBlock, SearchResultAddressOrContract, SearchResultTx, SearchResultLabel, SearchResult } from 'types/api/search'; import type {
SearchResultToken,
SearchResultBlock,
SearchResultAddressOrContract,
SearchResultTx,
SearchResultLabel,
SearchResult,
SearchResultUserOp,
} from 'types/api/search';
export const token1: SearchResultToken = { export const token1: SearchResultToken = {
address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B', address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
...@@ -101,6 +109,13 @@ export const tx1: SearchResultTx = { ...@@ -101,6 +109,13 @@ export const tx1: SearchResultTx = {
url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd', url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
}; };
export const userOp1: SearchResultUserOp = {
timestamp: '2024-01-11T14:15:48.000000Z',
type: 'user_operation',
user_operation_hash: '0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
};
export const baseResponse: SearchResult = { export const baseResponse: SearchResult = {
items: [ items: [
token1, token1,
......
import type { UserOp } from 'types/api/userOps';
export const userOpData: UserOp = {
timestamp: '2024-01-19T12:42:12.000000Z',
transaction_hash: '0x715fe1474ac7bea3d6f4a03b1c5b6d626675fb0b103be29f849af65e9f1f9c6a',
user_logs_start_index: 40,
fee: '187125856691380',
call_gas_limit: '26624',
gas: '258875',
status: true,
aggregator_signature: null,
block_hash: '0xff5f41ec89e5fb3dfcf103bbbd67469fed491a7dd7cffdf00bd9e3bf45d8aeab',
pre_verification_gas: '48396',
factory: null,
signature: '0x2b95a173c1ea314d2c387e0d84194d221c14805e02157b7cefaf607a53e9081c0099ccbeaa1020ab91b862d4a4743dc1e20b4953f5bb6c13afeac760cef34fd11b',
verification_gas_limit: '61285',
max_fee_per_gas: '1575000898',
aggregator: null,
hash: '0xe72500491b3f2549ac53bd9de9dbb1d2edfc33cdddf5c079d6d64dfec650ef83',
gas_price: '1575000898',
user_logs_count: 1,
block_number: '10399597',
gas_used: '118810',
sender: {
ens_domain_name: null,
hash: '0xF0C14FF4404b188fAA39a3507B388998c10FE284',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
nonce: '0x000000000000000000000000000000000000000000000000000000000000004f',
entry_point: {
ens_domain_name: null,
hash: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
sponsor_type: 'paymaster_sponsor',
raw: {
// eslint-disable-next-line max-len
call_data: '0xb61d27f600000000000000000000000059f6aa952df7f048fd076e33e0ea8bb552d5ffd8000000000000000000000000000000000000000000000000003f3d017500800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000',
call_gas_limit: '26624',
init_code: '0x',
max_fee_per_gas: '1575000898',
max_priority_fee_per_gas: '1575000898',
nonce: '79',
// eslint-disable-next-line max-len
paymaster_and_data: '0x7cea357b5ac0639f89f9e378a1f03aa5005c0a250000000000000000000000000000000000000000000000000000000065b3a8800000000000000000000000000000000000000000000000000000000065aa6e0028fa4c57ac1141bc9ecd8c9243f618ade8ea1db10ab6c1d1798a222a824764ff2269a72ae7a3680fa8b03a80d8a00cdc710eaf37afdcc55f8c9c4defa3fdf2471b',
pre_verification_gas: '48396',
sender: '0xF0C14FF4404b188fAA39a3507B388998c10FE284',
signature: '0x2b95a173c1ea314d2c387e0d84194d221c14805e02157b7cefaf607a53e9081c0099ccbeaa1020ab91b862d4a4743dc1e20b4953f5bb6c13afeac760cef34fd11b',
verification_gas_limit: '61285',
},
max_priority_fee_per_gas: '1575000898',
revert_reason: null,
bundler: {
ens_domain_name: null,
hash: '0xd53Eb5203e367BbDD4f72338938224881Fc501Ab',
implementation_name: null,
is_contract: false,
is_verified: null,
name: null,
},
// eslint-disable-next-line max-len
call_data: '0xb61d27f600000000000000000000000059f6aa952df7f048fd076e33e0ea8bb552d5ffd8000000000000000000000000000000000000000000000000003f3d017500800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000',
paymaster: {
ens_domain_name: null,
hash: '0x7ceA357B5AC0639F89F9e378a1f03Aa5005C0a25',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
};
import type { UserOpsResponse } from 'types/api/userOps';
export const userOpsData: UserOpsResponse = {
items: [
{
address: {
ens_domain_name: null,
hash: '0xF0C14FF4404b188fAA39a3507B388998c10FE284',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
block_number: '10399597',
fee: '187125856691380',
hash: '0xe72500491b3f2549ac53bd9de9dbb1d2edfc33cdddf5c079d6d64dfec650ef83',
status: true,
timestamp: '2022-01-19T12:42:12.000000Z',
transaction_hash: '0x715fe1474ac7bea3d6f4a03b1c5b6d626675fb0b103be29f849af65e9f1f9c6a',
},
{
address:
{ ens_domain_name: null,
hash: '0x2c298CcaFFD1549e1C21F46966A6c236fCC66dB2',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
block_number: '10399596',
fee: '381895502291373',
hash: '0xcb945ae86608bdc88c3318245403c81a880fcb1e49fef18ac59477761c056cea',
status: false,
timestamp: '2022-01-19T12:42:00.000000Z',
transaction_hash: '0x558d699e7cbc235461d48ed04b8c3892d598a4000f20851760d00dc3513c2e48',
},
{
address: {
ens_domain_name: null,
hash: '0x2c298CcaFFD1549e1C21F46966A6c236fCC66dB2',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
block_number: '10399560',
fee: '165019501210143',
hash: '0x84c1270b12af3f0ffa204071f1bf503ebf9b1ccf6310680383be5a2b6fd1d8e5',
status: true,
timestamp: '2022-01-19T12:32:00.000000Z',
transaction_hash: '0xc4c1c38680ec63139411aa2193275e8de44be15217c4256db9473bf0ea2b6264',
},
],
next_page_params: {
page_size: 50,
page_token: '10396582,0x9bf4d2a28813c5c244884cb20cdfe01dabb3f927234ae961eab6e38502de7a28',
},
};
...@@ -147,3 +147,13 @@ export const accounts: GetServerSideProps<Props> = async(context) => { ...@@ -147,3 +147,13 @@ export const accounts: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const userOps: GetServerSideProps<Props> = async(context) => {
if (!config.features.userOps.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
...@@ -39,6 +39,8 @@ declare module "nextjs-routes" { ...@@ -39,6 +39,8 @@ declare module "nextjs-routes" {
| StaticRoute<"/login"> | StaticRoute<"/login">
| DynamicRoute<"/name-domains/[name]", { "name": string }> | DynamicRoute<"/name-domains/[name]", { "name": string }>
| StaticRoute<"/name-domains"> | StaticRoute<"/name-domains">
| DynamicRoute<"/op/[hash]", { "hash": string }>
| StaticRoute<"/ops">
| StaticRoute<"/search-results"> | StaticRoute<"/search-results">
| StaticRoute<"/stats"> | StaticRoute<"/stats">
| DynamicRoute<"/token/[hash]", { "hash": string }> | DynamicRoute<"/token/[hash]", { "hash": string }>
......
...@@ -4,8 +4,8 @@ ...@@ -4,8 +4,8 @@
"private": false, "private": false,
"homepage": "https://github.com/blockscout/frontend#readme", "homepage": "https://github.com/blockscout/frontend#readme",
"engines": { "engines": {
"node": "18", "node": "20.11.0",
"npm": "8" "npm": "10.2.4"
}, },
"scripts": { "scripts": {
"dev": "./tools/scripts/dev.sh", "dev": "./tools/scripts/dev.sh",
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
"svg:build-sprite": "icons build -i ./icons -o ./public/icons --optimize", "svg:build-sprite": "icons build -i ./icons -o ./public/icons --optimize",
"test:pw": "./tools/scripts/pw.sh", "test:pw": "./tools/scripts/pw.sh",
"test:pw:local": "export NODE_PATH=$(pwd)/node_modules && yarn test:pw", "test:pw:local": "export NODE_PATH=$(pwd)/node_modules && yarn test:pw",
"test:pw:docker": "docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.35.1-focal ./tools/scripts/pw.docker.sh", "test:pw:docker": "docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-focal ./tools/scripts/pw.docker.sh",
"test:pw:ci": "yarn test:pw --project=$PW_PROJECT", "test:pw:ci": "yarn test:pw --project=$PW_PROJECT",
"test:jest": "jest", "test:jest": "jest",
"test:jest:watch": "jest --watch", "test:jest:watch": "jest --watch",
...@@ -99,8 +99,8 @@ ...@@ -99,8 +99,8 @@
"xss": "^1.0.14" "xss": "^1.0.14"
}, },
"devDependencies": { "devDependencies": {
"@playwright/experimental-ct-react": "1.35.1", "@playwright/experimental-ct-react": "1.41.1",
"@playwright/test": "^1.35.1", "@playwright/test": "1.41.1",
"@svgr/webpack": "^6.5.1", "@svgr/webpack": "^6.5.1",
"@tanstack/eslint-plugin-query": "^5.0.5", "@tanstack/eslint-plugin-query": "^5.0.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
...@@ -112,7 +112,7 @@ ...@@ -112,7 +112,7 @@
"@types/jest": "^29.2.0", "@types/jest": "^29.2.0",
"@types/js-cookie": "^3.0.2", "@types/js-cookie": "^3.0.2",
"@types/mixpanel-browser": "^2.38.1", "@types/mixpanel-browser": "^2.38.1",
"@types/node": "18.11.18", "@types/node": "20.11.0",
"@types/phoenix": "^1.5.4", "@types/phoenix": "^1.5.4",
"@types/qrcode": "^1.5.0", "@types/qrcode": "^1.5.0",
"@types/react": "18.0.9", "@types/react": "18.0.9",
......
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 UserOp = dynamic(() => import('ui/pages/UserOp'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/op/[hash]" query={ props }>
<UserOp/>
</PageNextJs>
);
};
export default Page;
export { userOps as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const UserOps = dynamic(() => import('ui/pages/UserOps'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/ops">
<UserOps/>
</PageNextJs>
);
};
export default Page;
export { userOps as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -36,6 +36,9 @@ export const featureEnvs = { ...@@ -36,6 +36,9 @@ export const featureEnvs = {
{ name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' }, { name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' },
{ name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' }, { name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' },
], ],
userOps: [
{ name: 'NEXT_PUBLIC_HAS_USER_OPS', value: 'true' },
],
}; };
export const viewsEnvs = { export const viewsEnvs = {
......
// This file is generated by npm run build:icons // This file is generated by npm run build:icons
export type IconName = export type IconName =
| "ABI" | "ABI_slim"
| "ABI"
| "API" | "API"
| "apps" | "apps"
| "arrows/down-right" | "arrows/down-right"
...@@ -27,6 +28,7 @@ ...@@ -27,6 +28,7 @@
| "discussions" | "discussions"
| "docs" | "docs"
| "donate" | "donate"
| "dots"
| "edit" | "edit"
| "email-sent" | "email-sent"
| "email" | "email"
...@@ -125,10 +127,11 @@ ...@@ -125,10 +127,11 @@
| "txn_batches" | "txn_batches"
| "unfinalized" | "unfinalized"
| "uniswap" | "uniswap"
| "user_op_slim"
| "user_op"
| "verified_token" | "verified_token"
| "verified" | "verified"
| "verify-contract" | "verify-contract"
| "vertical_dots"
| "wallet" | "wallet"
| "wallets/coinbase" | "wallets/coinbase"
| "wallets/metamask" | "wallets/metamask"
......
import type { Chain, GetBlockReturnType, GetTransactionReturnType, TransactionReceipt, Withdrawal } from 'viem';
import { ADDRESS_HASH } from './addressParams';
import { BLOCK_HASH } from './block';
import { TX_HASH } from './tx';
export const WITHDRAWAL: Withdrawal = {
index: '0x1af95d9',
validatorIndex: '0x7d748',
address: '0x9b52b9033ecbb6635f1c31a646d5691b282878aa',
amount: '0x29e16b',
};
export const GET_TRANSACTION: GetTransactionReturnType<Chain, 'latest'> = {
blockHash: BLOCK_HASH,
blockNumber: BigInt(10361367),
from: ADDRESS_HASH,
gas: BigInt(800000),
maxPriorityFeePerGas: BigInt(2),
maxFeePerGas: BigInt(14),
hash: TX_HASH,
input: '0x7898e0',
nonce: 117694,
to: ADDRESS_HASH,
transactionIndex: 60,
value: BigInt(42),
type: 'eip1559',
accessList: [],
chainId: 5,
v: BigInt(0),
r: '0x2c5022ff7f78a22f1a99afbd568f75cb52812189ed8c264c8310e0b8dba2c8a8',
s: '0x50938f87c92b9eeb9777507ca8f7397840232d00d1dbac3edac6c115b4656763',
yParity: 1,
typeHex: '0x2',
};
export const GET_TRANSACTION_RECEIPT: TransactionReceipt = {
blockHash: BLOCK_HASH,
blockNumber: BigInt(10361367),
contractAddress: null,
cumulativeGasUsed: BigInt(39109),
effectiveGasPrice: BigInt(13),
from: ADDRESS_HASH,
gasUsed: BigInt(39109),
logs: [],
logsBloom: '0x0',
status: 'success',
to: ADDRESS_HASH,
transactionHash: TX_HASH,
transactionIndex: 60,
type: '0x2',
};
export const GET_TRANSACTION_CONFIRMATIONS = BigInt(420);
export const GET_BLOCK: GetBlockReturnType<Chain, false, 'latest'> = {
baseFeePerGas: BigInt(11),
difficulty: BigInt(111),
extraData: '0xd8830',
gasLimit: BigInt(800000),
gasUsed: BigInt(42000),
hash: BLOCK_HASH,
logsBloom: '0x008000',
miner: ADDRESS_HASH,
mixHash: BLOCK_HASH,
nonce: '0x0000000000000000',
number: BigInt(10361367),
parentHash: BLOCK_HASH,
receiptsRoot: BLOCK_HASH,
sha3Uncles: BLOCK_HASH,
size: BigInt(88),
stateRoot: BLOCK_HASH,
timestamp: BigInt(1628580000),
totalDifficulty: BigInt(10361367),
transactions: [
TX_HASH,
],
transactionsRoot: TX_HASH,
uncles: [],
withdrawals: Array(10).fill(WITHDRAWAL),
withdrawalsRoot: TX_HASH,
sealFields: [ '0x00' ],
};
export const GET_BLOCK_WITH_TRANSACTIONS: GetBlockReturnType<Chain, true, 'latest'> = {
...GET_BLOCK,
transactions: Array(50).fill(GET_TRANSACTION),
};
import type { UserOpsItem, UserOp, UserOpsAccount } from 'types/api/userOps';
import { ADDRESS_HASH } from './addressParams';
import { BLOCK_HASH } from './block';
import { TX_HASH } from './tx';
const USER_OP_HASH = '0xb94fab8f31f83001a23e20b2ce3cdcfb284c57a64b9a073e0e09c018bc701978';
export const USER_OPS_ITEM: UserOpsItem = {
hash: USER_OP_HASH,
block_number: '10356381',
transaction_hash: TX_HASH,
address: ADDRESS_HASH,
timestamp: '2023-12-18T10:48:49.000000Z',
status: true,
fee: '48285720012071430',
};
export const USER_OP: UserOp = {
hash: USER_OP_HASH,
sender: ADDRESS_HASH,
nonce: '0x00b',
call_data: '0x123',
call_gas_limit: '71316',
verification_gas_limit: '91551',
pre_verification_gas: '53627',
max_fee_per_gas: '100000020',
max_priority_fee_per_gas: '100000000',
signature: '0x000',
aggregator: null,
aggregator_signature: null,
entry_point: ADDRESS_HASH,
transaction_hash: TX_HASH,
block_number: '10358181',
block_hash: BLOCK_HASH,
bundler: ADDRESS_HASH,
factory: null,
paymaster: ADDRESS_HASH,
status: true,
revert_reason: null,
gas: '399596',
gas_price: '1575000898',
gas_used: '118810',
sponsor_type: 'paymaster_sponsor',
fee: '17927001792700',
timestamp: '2023-12-18T10:48:49.000000Z',
user_logs_count: 1,
user_logs_start_index: 2,
raw: {
sender: ADDRESS_HASH,
nonce: '1',
init_code: '0x',
call_data: '0x345',
call_gas_limit: '29491',
verification_gas_limit: '80734',
pre_verification_gas: '3276112',
max_fee_per_gas: '309847206',
max_priority_fee_per_gas: '100000000',
paymaster_and_data: '0x',
signature: '0x000',
},
};
export const USER_OPS_ACCOUNT: UserOpsAccount = {
total_ops: 1,
};
...@@ -56,10 +56,10 @@ const variantSubtle = definePartsStyle((props) => { ...@@ -56,10 +56,10 @@ const variantSubtle = definePartsStyle((props) => {
return { return {
container: { container: {
[$fg.variable]: colorScheme === 'gray' ? 'colors.blackAlpha.800' : `colors.${ colorScheme }.500`, [$fg.variable]: colorScheme === 'gray' ? 'colors.blackAlpha.800' : `colors.${ colorScheme }.500`,
[$bg.variable]: colorScheme === 'gray' ? 'colors.gray.100' : bg.light, [$bg.variable]: colorScheme === 'gray' ? 'colors.blackAlpha.100' : bg.light,
_dark: { _dark: {
[$fg.variable]: colorScheme === 'gray' ? 'colors.whiteAlpha.800' : `colors.${ colorScheme }.200`, [$fg.variable]: colorScheme === 'gray' ? 'colors.whiteAlpha.800' : `colors.${ colorScheme }.200`,
[$bg.variable]: colorScheme === 'gray' ? 'colors.gray.800' : bg.dark, [$bg.variable]: colorScheme === 'gray' ? 'colors.whiteAlpha.200' : bg.dark,
}, },
}, },
}; };
......
...@@ -15,7 +15,7 @@ export interface UserTags { ...@@ -15,7 +15,7 @@ export interface UserTags {
public_tags: Array<AddressTag> | null; public_tags: Array<AddressTag> | null;
} }
export interface AddressParam extends UserTags { export type AddressParamBasic = {
hash: string; hash: string;
implementation_name: string | null; implementation_name: string | null;
name: string | null; name: string | null;
...@@ -23,3 +23,5 @@ export interface AddressParam extends UserTags { ...@@ -23,3 +23,5 @@ export interface AddressParam extends UserTags {
is_verified: boolean | null; is_verified: boolean | null;
ens_domain_name: string | null; ens_domain_name: string | null;
} }
export type AddressParam = UserTags & AddressParamBasic;
...@@ -13,7 +13,7 @@ export interface Block { ...@@ -13,7 +13,7 @@ export interface Block {
hash: string; hash: string;
parent_hash: string; parent_hash: string;
difficulty: string; difficulty: string;
total_difficulty: string; total_difficulty: string | null;
gas_used: string | null; gas_used: string | null;
gas_limit: string; gas_limit: string;
nonce: string; nonce: string;
...@@ -69,7 +69,7 @@ export type BlockWithdrawalsResponse = { ...@@ -69,7 +69,7 @@ export type BlockWithdrawalsResponse = {
next_page_params: { next_page_params: {
index: number; index: number;
items_count: number; items_count: number;
}; } | null;
} }
export type BlockWithdrawalsItem = { export type BlockWithdrawalsItem = {
......
...@@ -55,7 +55,14 @@ export interface SearchResultTx { ...@@ -55,7 +55,14 @@ 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 type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel; export interface SearchResultUserOp {
type: 'user_operation';
user_operation_hash: string;
timestamp: string;
url?: string; // not used by the frontend, we build the url ourselves
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp;
export interface SearchResult { export interface SearchResult {
items: Array<SearchResultItem>; items: Array<SearchResultItem>;
...@@ -79,5 +86,5 @@ export interface SearchResultFilters { ...@@ -79,5 +86,5 @@ export interface SearchResultFilters {
export interface SearchRedirectResult { export interface SearchRedirectResult {
parameter: string | null; parameter: string | null;
redirect: boolean; redirect: boolean;
type: 'address' | 'block' | 'transaction' | null; type: 'address' | 'block' | 'transaction' | 'user_operation' | null;
} }
...@@ -26,14 +26,14 @@ export type Transaction = { ...@@ -26,14 +26,14 @@ export type Transaction = {
hash: string; hash: string;
result: string; result: string;
confirmations: number; confirmations: number;
status: 'ok' | 'error' | null; status: 'ok' | 'error' | null | undefined;
block: number | null; block: number | null;
timestamp: string | null; timestamp: string | null;
confirmation_duration: Array<number>; confirmation_duration: Array<number> | null;
from: AddressParam; from: AddressParam;
value: string; value: string;
fee: Fee; fee: Fee;
gas_price: string; gas_price: string | null;
type: number | null; type: number | null;
gas_used: string | null; gas_used: string | null;
gas_limit: string; gas_limit: string;
...@@ -49,7 +49,7 @@ export type Transaction = { ...@@ -49,7 +49,7 @@ export type Transaction = {
decoded_input: DecodedInput | null; decoded_input: DecodedInput | null;
token_transfers: Array<TokenTransfer> | null; token_transfers: Array<TokenTransfer> | null;
token_transfers_overflow: boolean; token_transfers_overflow: boolean;
exchange_rate: string; exchange_rate: string | null;
method: string | null; method: string | null;
tx_types: Array<TransactionType>; tx_types: Array<TransactionType>;
tx_tag: string | null; tx_tag: string | null;
......
import type { AddressParamBasic } from './addressParams';
export type UserOpsItem = {
hash: string;
block_number: string;
transaction_hash: string;
address: string | AddressParamBasic;
timestamp: string;
status: boolean;
fee: string;
}
export type UserOpsResponse = {
items: Array<UserOpsItem>;
next_page_params: {
page_token: string;
page_size: number;
} | null;
}
export type UserOpSponsorType = 'paymaster_hybrid' | 'paymaster_sponsor' | 'wallet_balance' | 'wallet_deposit';
export type UserOp = {
hash: string;
sender: string | AddressParamBasic;
status: boolean;
revert_reason: string | null;
timestamp: string | null;
fee: string;
gas: string;
transaction_hash: string;
block_number: string;
block_hash: string;
entry_point: string | AddressParamBasic;
call_gas_limit: string;
verification_gas_limit: string;
pre_verification_gas: string;
max_fee_per_gas: string;
max_priority_fee_per_gas: string;
aggregator: string | null;
aggregator_signature: string | null;
bundler: string | AddressParamBasic;
factory: string | null;
paymaster: string | AddressParamBasic | null;
sponsor_type: UserOpSponsorType;
signature: string;
nonce: string;
call_data: string;
user_logs_start_index: number;
user_logs_count: number;
raw: {
call_data: string;
call_gas_limit: string;
init_code: string;
max_fee_per_gas: string;
max_priority_fee_per_gas: string;
nonce: string;
paymaster_and_data: string;
pre_verification_gas: string;
sender: string;
signature: string;
verification_gas_limit: string;
};
gas_price: string;
gas_used: string;
}
export type UserOpsFilters = {
transaction_hash?: string;
sender?: string;
}
export type UserOpsAccount = {
total_ops: number;
}
export interface ContractCodeIde {
title: string;
url: string;
icon_url: string;
}
...@@ -10,6 +10,7 @@ import config from 'configs/app'; ...@@ -10,6 +10,7 @@ import config from 'configs/app';
import { getResourceKey } from 'lib/api/useApiQuery'; import { getResourceKey } from 'lib/api/useApiQuery';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage'; import useSocketMessage from 'lib/socket/useSocketMessage';
import { currencyUnits } from 'lib/units';
import { BLOCK } from 'stubs/block'; import { BLOCK } from 'stubs/block';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
...@@ -95,7 +96,7 @@ const AddressBlocksValidated = ({ scrollRef }: Props) => { ...@@ -95,7 +96,7 @@ const AddressBlocksValidated = ({ scrollRef }: Props) => {
<Th width="16%">Txn</Th> <Th width="16%">Txn</Th>
<Th width="25%">Gas used</Th> <Th width="25%">Gas used</Th>
{ !config.UI.views.block.hiddenFields?.total_reward && { !config.UI.views.block.hiddenFields?.total_reward &&
<Th width="25%" isNumeric>Reward { config.chain.currency.symbol }</Th> } <Th width="25%" isNumeric>Reward { currencyUnits.ether }</Th> }
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
......
...@@ -7,6 +7,7 @@ import type { Address as TAddress } from 'types/api/address'; ...@@ -7,6 +7,7 @@ import type { Address as TAddress } from 'types/api/address';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_COUNTERS } from 'stubs/address'; import { ADDRESS_COUNTERS } from 'stubs/address';
import AddressCounterItem from 'ui/address/details/AddressCounterItem'; import AddressCounterItem from 'ui/address/details/AddressCounterItem';
...@@ -68,7 +69,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => { ...@@ -68,7 +69,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
const is422Error = addressQuery.isError && 'status' in addressQuery.error && addressQuery.error.status === 422; const is422Error = addressQuery.isError && 'status' in addressQuery.error && addressQuery.error.status === 422;
if (addressQuery.isError && is422Error) { if (addressQuery.isError && is422Error) {
throw Error('Address fetch error', { cause: addressQuery.error as unknown as Error }); throwOnResourceLoadError(addressQuery);
} }
if (addressQuery.isError && !is404Error) { if (addressQuery.isError && !is404Error) {
......
import { useRouter } from 'next/router';
import React from 'react';
import getQueryParamString from 'lib/router/getQueryParamString';
import { USER_OPS_ITEM } from 'stubs/userOps';
import { generateListStub } from 'stubs/utils';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import UserOpsContent from 'ui/userOps/UserOpsContent';
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
}
const AddressUserOps = ({ scrollRef }: Props) => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const userOpsQuery = useQueryWithPages({
resourceName: 'user_ops',
scrollRef,
options: {
enabled: Boolean(hash),
placeholderData: generateListStub<'user_ops'>(USER_OPS_ITEM, 50, { next_page_params: {
page_token: '10355938,0x5956a847d8089e254e02e5111cad6992b99ceb9e5c2dc4343fd53002834c4dc6',
page_size: 50,
} }),
},
filters: { sender: hash },
});
return <UserOpsContent query={ userOpsQuery } showSender={ false }/>;
};
export default AddressUserOps;
...@@ -112,7 +112,7 @@ const SolidityscanReport = ({ className, hash }: Props) => { ...@@ -112,7 +112,7 @@ const SolidityscanReport = ({ className, hash }: Props) => {
return ( return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy> <Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger> <PopoverTrigger>
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData } borderRadius="base">
<Button <Button
className={ className } className={ className }
color={ scoreColor } color={ scoreColor }
...@@ -122,7 +122,7 @@ const SolidityscanReport = ({ className, hash }: Props) => { ...@@ -122,7 +122,7 @@ const SolidityscanReport = ({ className, hash }: Props) => {
onClick={ onToggle } onClick={ onToggle }
aria-label="SolidityScan score" aria-label="SolidityScan score"
fontWeight={ 500 } fontWeight={ 500 }
px={ 2 } px="6px"
h="32px" h="32px"
flexShrink={ 0 } flexShrink={ 0 }
> >
......
...@@ -7,6 +7,7 @@ import type { Block } from 'types/api/block'; ...@@ -7,6 +7,7 @@ import type { Block } from 'types/api/block';
import config from 'configs/app'; import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
...@@ -50,7 +51,7 @@ const AddressBlocksValidatedListItem = (props: Props) => { ...@@ -50,7 +51,7 @@ const AddressBlocksValidatedListItem = (props: Props) => {
</Flex> </Flex>
{ !config.UI.views.block.hiddenFields?.total_reward && ( { !config.UI.views.block.hiddenFields?.total_reward && (
<Flex columnGap={ 2 } w="100%"> <Flex columnGap={ 2 } w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Reward { config.chain.currency.symbol }</Skeleton> <Skeleton isLoaded={ !props.isLoading } fontWeight={ 500 } flexShrink={ 0 }>Reward { currencyUnits.ether }</Skeleton>
<Skeleton isLoaded={ !props.isLoading } color="text_secondary">{ totalReward.toFixed() }</Skeleton> <Skeleton isLoaded={ !props.isLoading } color="text_secondary">{ totalReward.toFixed() }</Skeleton>
</Flex> </Flex>
) } ) }
......
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ 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 { currencyUnits } from 'lib/units';
import ChartWidget from 'ui/shared/chart/ChartWidget'; import ChartWidget from 'ui/shared/chart/ChartWidget';
interface Props { interface Props {
...@@ -26,7 +27,7 @@ const AddressCoinBalanceChart = ({ addressHash }: Props) => { ...@@ -26,7 +27,7 @@ const AddressCoinBalanceChart = ({ addressHash }: Props) => {
items={ items } items={ items }
isLoading={ isPending } isLoading={ isPending }
h="300px" h="300px"
units={ config.chain.currency.symbol } units={ currencyUnits.ether }
/> />
); );
}; };
......
...@@ -5,8 +5,8 @@ import React from 'react'; ...@@ -5,8 +5,8 @@ import React from 'react';
import type { AddressCoinBalanceHistoryResponse } from 'types/api/address'; import type { AddressCoinBalanceHistoryResponse } from 'types/api/address';
import type { PaginationParams } from 'ui/shared/pagination/types'; import type { PaginationParams } from 'ui/shared/pagination/types';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import { currencyUnits } from 'lib/units';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
...@@ -32,7 +32,7 @@ const AddressCoinBalanceHistory = ({ query }: Props) => { ...@@ -32,7 +32,7 @@ const AddressCoinBalanceHistory = ({ query }: Props) => {
<Th width="20%">Block</Th> <Th width="20%">Block</Th>
<Th width="20%">Txn</Th> <Th width="20%">Txn</Th>
<Th width="20%">Age</Th> <Th width="20%">Age</Th>
<Th width="20%" isNumeric pr={ 1 }>Balance { config.chain.currency.symbol }</Th> <Th width="20%" isNumeric pr={ 1 }>Balance { currencyUnits.ether }</Th>
<Th width="20%" isNumeric>Delta</Th> <Th width="20%" isNumeric>Delta</Th>
</Tr> </Tr>
</Thead> </Thead>
......
...@@ -4,9 +4,9 @@ import React from 'react'; ...@@ -4,9 +4,9 @@ import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address'; import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import config from 'configs/app';
import { WEI, ZERO } from 'lib/consts'; import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
...@@ -25,7 +25,7 @@ const AddressCoinBalanceListItem = (props: Props) => { ...@@ -25,7 +25,7 @@ const AddressCoinBalanceListItem = (props: Props) => {
<ListItemMobile rowGap={ 2 } isAnimated> <ListItemMobile rowGap={ 2 } isAnimated>
<Flex justifyContent="space-between" w="100%"> <Flex justifyContent="space-between" w="100%">
<Skeleton isLoaded={ !props.isLoading } fontWeight={ 600 }> <Skeleton isLoaded={ !props.isLoading } fontWeight={ 600 }>
{ BigNumber(props.value).div(WEI).dp(8).toFormat() } { config.chain.currency.symbol } { BigNumber(props.value).div(WEI).dp(8).toFormat() } { currencyUnits.ether }
</Skeleton> </Skeleton>
<Skeleton isLoaded={ !props.isLoading }> <Skeleton isLoaded={ !props.isLoading }>
<Stat flexGrow="0"> <Stat flexGrow="0">
......
...@@ -122,6 +122,9 @@ test('verified with multiple sources', async({ mount, page }) => { ...@@ -122,6 +122,9 @@ test('verified with multiple sources', async({ mount, page }) => {
await page.getByRole('button', { name: 'View external libraries' }).click(); await page.getByRole('button', { name: 'View external libraries' }).click();
await expect(section).toHaveScreenshot(); await expect(section).toHaveScreenshot();
await page.getByRole('button', { name: 'Open source code in IDE' }).click();
await expect(section).toHaveScreenshot();
}); });
test('verified via sourcify', async({ mount, page }) => { test('verified via sourcify', async({ mount, page }) => {
......
import { Flex, Button, chakra, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, Image, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
interface Props {
className?: string;
hash: string;
}
const ContractCodeIde = ({ className, hash }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const defaultIconColor = useColorModeValue('gray.600', 'gray.500');
const ideLinks = React.useMemo(() => {
return config.UI.ides.items
.map((ide) => {
const url = decodeURIComponent(ide.url.replace('{hash}', hash).replace('{domain}', config.app.host || ''));
const icon = 'icon_url' in ide ?
<Image boxSize={ 5 } mr={ 2 } src={ ide.icon_url } alt={ `${ ide.title } icon` }/> :
<IconSvg name="ABI_slim" boxSize={ 5 } color={ defaultIconColor } mr={ 2 }/>;
return (
<LinkExternal key={ ide.title } href={ url } display="inline-flex" alignItems="center">
{ icon }
{ ide.title }
</LinkExternal>
);
});
}, [ defaultIconColor, hash ]);
if (ideLinks.length === 0) {
return null;
}
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Button
className={ className }
size="sm"
variant="outline"
colorScheme="gray"
onClick={ onToggle }
aria-label="Open source code in IDE"
fontWeight={ 500 }
px={ 2 }
h="32px"
flexShrink={ 0 }
>
<span>Open in</span>
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 }/>
</Button>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody >
<chakra.span color="text_secondary" fontSize="xs">Redactors</chakra.span>
<Flex
flexDir="column"
alignItems="flex-start"
columnGap={ 6 }
rowGap={ 3 }
mt={ 3 }
>
{ ideLinks }
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(chakra(ContractCodeIde));
import { Alert, Button, Flex } from '@chakra-ui/react'; import { Alert, Button, Flex } from '@chakra-ui/react';
import { useWeb3Modal, useWeb3ModalState } from '@web3modal/wagmi/react';
import React from 'react'; import React from 'react';
import { useAccount, useDisconnect } from 'wagmi';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import useWallet from 'ui/snippets/walletMenu/useWallet';
const ContractConnectWallet = () => { const ContractConnectWallet = () => {
const { open } = useWeb3Modal(); const { isModalOpening, isModalOpen, connect, disconnect, address, isWalletConnected } = useWallet({ source: 'Smart contracts' });
const { open: isOpen } = useWeb3ModalState();
const { disconnect } = useDisconnect();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [ isModalOpening, setIsModalOpening ] = React.useState(false);
const handleConnect = React.useCallback(async() => {
setIsModalOpening(true);
await open();
setIsModalOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Smart contracts', Status: 'Started' });
}, [ open ]);
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => {
!isReconnected && mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Smart contracts', Status: 'Connected' });
}, []);
const handleDisconnect = React.useCallback(() => {
disconnect();
}, [ disconnect ]);
const { address, isDisconnected } = useAccount({ onConnect: handleAccountConnected });
const content = (() => { const content = (() => {
if (isDisconnected || !address) { if (!isWalletConnected) {
return ( return (
<> <>
<span>Disconnected</span> <span>Disconnected</span>
<Button <Button
ml={ 3 } ml={ 3 }
onClick={ handleConnect } onClick={ connect }
size="sm" size="sm"
variant="outline" variant="outline"
isLoading={ isModalOpening || isOpen } isLoading={ isModalOpening || isModalOpen }
loadingText="Connect wallet" loadingText="Connect wallet"
> >
Connect wallet Connect wallet
...@@ -61,7 +39,7 @@ const ContractConnectWallet = () => { ...@@ -61,7 +39,7 @@ const ContractConnectWallet = () => {
ml={ 2 } ml={ 2 }
/> />
</Flex> </Flex>
<Button onClick={ handleDisconnect } size="sm" variant="outline">Disconnect</Button> <Button onClick={ disconnect } size="sm" variant="outline">Disconnect</Button>
</Flex> </Flex>
); );
})(); })();
......
...@@ -39,7 +39,7 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -39,7 +39,7 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
return [ return [
...('inputs' in data ? data.inputs : []), ...('inputs' in data ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ { ...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol }`, name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const, type: 'uint256' as const,
internalType: 'uint256' as const, internalType: 'uint256' as const,
fieldType: 'native_coin' as const, fieldType: 'native_coin' as const,
......
...@@ -5,8 +5,8 @@ import React from 'react'; ...@@ -5,8 +5,8 @@ import React from 'react';
import type { SmartContractMethodOutput } from 'types/api/contract'; import type { SmartContractMethodOutput } from 'types/api/contract';
import config from 'configs/app';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
function castValueToString(value: number | string | boolean | object | bigint | undefined): string { function castValueToString(value: number | string | boolean | object | bigint | undefined): string {
...@@ -32,17 +32,17 @@ interface Props { ...@@ -32,17 +32,17 @@ interface Props {
const ContractMethodStatic = ({ data }: Props) => { const ContractMethodStatic = ({ data }: Props) => {
const [ value, setValue ] = React.useState<string>(castValueToString(data.value)); const [ value, setValue ] = React.useState<string>(castValueToString(data.value));
const [ label, setLabel ] = React.useState('WEI'); const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase());
const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => { const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const initialValue = castValueToString(data.value); const initialValue = castValueToString(data.value);
if (event.target.checked) { if (event.target.checked) {
setValue(BigNumber(initialValue).div(WEI).toFixed()); setValue(BigNumber(initialValue).div(WEI).toFixed());
setLabel(config.chain.currency.symbol || 'ETH'); setLabel(currencyUnits.ether.toUpperCase());
} else { } else {
setValue(BigNumber(initialValue).toFixed()); setValue(BigNumber(initialValue).toFixed());
setLabel('WEI'); setLabel(currencyUnits.wei.toUpperCase());
} }
}, [ data.value ]); }, [ data.value ]);
......
...@@ -13,6 +13,7 @@ import LinkInternal from 'ui/shared/LinkInternal'; ...@@ -13,6 +13,7 @@ import LinkInternal from 'ui/shared/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor'; import CodeEditor from 'ui/shared/monaco/CodeEditor';
import formatFilePath from 'ui/shared/monaco/utils/formatFilePath'; import formatFilePath from 'ui/shared/monaco/utils/formatFilePath';
import ContractCodeIdes from './ContractCodeIdes';
import ContractExternalLibraries from './ContractExternalLibraries'; import ContractExternalLibraries from './ContractExternalLibraries';
const SOURCE_CODE_OPTIONS = [ const SOURCE_CODE_OPTIONS = [
...@@ -116,6 +117,8 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -116,6 +117,8 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<CopyToClipboard text={ activeContractData[0].source_code } isLoading={ isLoading } ml={{ base: 'auto', lg: diagramLink ? '0' : 'auto' }}/> : <CopyToClipboard text={ activeContractData[0].source_code } isLoading={ isLoading } ml={{ base: 'auto', lg: diagramLink ? '0' : 'auto' }}/> :
null; null;
const ides = sourceType === 'secondary' ? <ContractCodeIdes hash={ implementationAddress }/> : <ContractCodeIdes hash={ address }/>;
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceType(event.target.value as SourceCodeType); setSourceType(event.target.value as SourceCodeType);
}, []); }, []);
...@@ -188,6 +191,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => { ...@@ -188,6 +191,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
{ editorSourceTypeSelector } { editorSourceTypeSelector }
{ externalLibraries } { externalLibraries }
{ diagramLink } { diagramLink }
{ ides }
{ copyToClipboard } { copyToClipboard }
</Flex> </Flex>
{ content } { content }
......
...@@ -129,7 +129,8 @@ function castValue(value: string, type: SmartContractMethodArgType) { ...@@ -129,7 +129,8 @@ function castValue(value: string, type: SmartContractMethodArgType) {
} }
const isNestedArray = (type.match(/\[/g) || []).length > 1; const isNestedArray = (type.match(/\[/g) || []).length > 1;
if (isNestedArray) { const isNestedTuple = type.includes('tuple');
if (isNestedArray || isNestedTuple) {
return parseArrayValue(value) || value; return parseArrayValue(value) || value;
} }
......
...@@ -8,6 +8,7 @@ import config from 'configs/app'; ...@@ -8,6 +8,7 @@ import config from 'configs/app';
import { getResourceKey } from 'lib/api/useApiQuery'; import { getResourceKey } from 'lib/api/useApiQuery';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage'; import useSocketMessage from 'lib/socket/useSocketMessage';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
...@@ -66,7 +67,7 @@ const AddressBalance = ({ data, isLoading }: Props) => { ...@@ -66,7 +67,7 @@ const AddressBalance = ({ data, isLoading }: Props) => {
return ( return (
<DetailsInfoItem <DetailsInfoItem
title="Balance" title="Balance"
hint={ `Address balance in ${ config.chain.currency.symbol }. Doesn't include ERC20, ERC721 and ERC1155 tokens` } hint={ `Address balance in ${ currencyUnits.ether }. Doesn't include ERC20, ERC721 and ERC1155 tokens` }
flexWrap="nowrap" flexWrap="nowrap"
alignItems="flex-start" alignItems="flex-start"
isLoading={ isLoading } isLoading={ isLoading }
...@@ -75,7 +76,7 @@ const AddressBalance = ({ data, isLoading }: Props) => { ...@@ -75,7 +76,7 @@ const AddressBalance = ({ data, isLoading }: Props) => {
value={ data.coin_balance || '0' } value={ data.coin_balance || '0' }
exchangeRate={ data.exchange_rate } exchangeRate={ data.exchange_rate }
decimals={ String(config.chain.currency.decimals) } decimals={ String(config.chain.currency.decimals) }
currency={ config.chain.currency.symbol } currency={ currencyUnits.ether }
accuracyUsd={ 2 } accuracyUsd={ 2 }
accuracy={ 8 } accuracy={ 8 }
flexWrap="wrap" flexWrap="wrap"
......
...@@ -31,7 +31,7 @@ test('base view', async({ mount, page }) => { ...@@ -31,7 +31,7 @@ test('base view', async({ mount, page }) => {
</TestApp>, </TestApp>,
); );
await component.getByText('4 domains').click(); await component.getByText('4').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 550, height: 350 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 550, height: 350 } });
}); });
import { Button, chakra, Flex, Grid, Popover, PopoverBody, PopoverContent, PopoverTrigger, Skeleton, useDisclosure } from '@chakra-ui/react'; import { Button, chakra, Flex, Grid, Hide, Popover, PopoverBody, PopoverContent, PopoverTrigger, Show, Skeleton, useDisclosure } from '@chakra-ui/react';
import _clamp from 'lodash/clamp'; import _clamp from 'lodash/clamp';
import React from 'react'; import React from 'react';
...@@ -50,7 +50,7 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => { ...@@ -50,7 +50,7 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => {
} }
if (isPending) { if (isPending) {
return <Skeleton h={ 8 } w={{ base: '60px', lg: '120px' }} borderRadius="base"/>; return <Skeleton h={ 8 } w={{ base: '50px', xl: '120px' }} borderRadius="base"/>;
} }
if (data.items.length === 0) { if (data.items.length === 0) {
...@@ -95,15 +95,19 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => { ...@@ -95,15 +95,19 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => {
variant="outline" variant="outline"
colorScheme="gray" colorScheme="gray"
onClick={ onToggle } onClick={ onToggle }
aria-label="Address ENS domains" aria-label="Address domains"
fontWeight={ 500 } fontWeight={ 500 }
px={ 2 } px={ 2 }
h="32px" h="32px"
flexShrink={ 0 } flexShrink={ 0 }
> >
<IconSvg name="ENS_slim" boxSize={ 5 }/> <IconSvg name="ENS_slim" boxSize={ 5 }/>
<chakra.span ml={ 1 } display={{ base: 'none', lg: 'block' }}>{ totalRecords } Domain{ data.items.length > 1 ? 's' : '' }</chakra.span> <Show above="xl">
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 }/> <chakra.span ml={ 1 }>{ totalRecords } Domain{ data.items.length > 1 ? 's' : '' }</chakra.span>
</Show>
<Hide above="xl">
<chakra.span ml={ 1 }>{ totalRecords }</chakra.span>
</Hide>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '500px' }}> <PopoverContent w={{ base: '100vw', lg: '500px' }}>
......
...@@ -6,6 +6,7 @@ import type { InternalTransaction } from 'types/api/internalTransaction'; ...@@ -6,6 +6,7 @@ import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
...@@ -68,7 +69,7 @@ const TxInternalsListItem = ({ ...@@ -68,7 +69,7 @@ const TxInternalsListItem = ({
w="100%" w="100%"
/> />
<HStack spacing={ 3 }> <HStack spacing={ 3 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Value { config.chain.currency.symbol }</Skeleton> <Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Value { currencyUnits.ether }</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" minW={ 6 }> <Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" minW={ 6 }>
<span>{ BigNumber(value).div(BigNumber(10 ** config.chain.currency.decimals)).toFormat() }</span> <span>{ BigNumber(value).div(BigNumber(10 ** config.chain.currency.decimals)).toFormat() }</span>
</Skeleton> </Skeleton>
......
...@@ -3,8 +3,8 @@ import React from 'react'; ...@@ -3,8 +3,8 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { currencyUnits } from 'lib/units';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
import AddressIntTxsTableItem from './AddressIntTxsTableItem'; import AddressIntTxsTableItem from './AddressIntTxsTableItem';
...@@ -26,7 +26,7 @@ const AddressIntTxsTable = ({ data, currentAddress, isLoading }: Props) => { ...@@ -26,7 +26,7 @@ const AddressIntTxsTable = ({ data, currentAddress, isLoading }: Props) => {
<Th width="10%">Block</Th> <Th width="10%">Block</Th>
<Th width="40%">From/To</Th> <Th width="40%">From/To</Th>
<Th width="20%" isNumeric> <Th width="20%" isNumeric>
Value { config.chain.currency.symbol } Value { currencyUnits.ether }
</Th> </Th>
</Tr> </Tr>
</Thead> </Thead>
......
...@@ -6,6 +6,7 @@ import config from 'configs/app'; ...@@ -6,6 +6,7 @@ import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { ZERO } from 'lib/consts'; import { ZERO } from 'lib/consts';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import { currencyUnits } from 'lib/units';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import { getTokensTotalInfo } from '../utils/tokenUtils'; import { getTokensTotalInfo } from '../utils/tokenUtils';
...@@ -52,8 +53,8 @@ const TokenBalances = () => { ...@@ -52,8 +53,8 @@ const TokenBalances = () => {
isLoading={ addressQuery.isPending || tokenQuery.isPending } isLoading={ addressQuery.isPending || tokenQuery.isPending }
/> />
<TokenBalancesItem <TokenBalancesItem
name={ `${ config.chain.currency.symbol } Balance` } name={ `${ currencyUnits.ether } Balance` }
value={ (!nativeUsd.eq(ZERO) ? `$${ nativeUsd.toFormat(2) } USD | ` : '') + `${ nativeValue } ${ config.chain.currency.symbol }` } value={ (!nativeUsd.eq(ZERO) ? `$${ nativeUsd.toFormat(2) } USD | ` : '') + `${ nativeValue } ${ currencyUnits.ether }` }
isLoading={ addressQuery.isPending || tokenQuery.isPending } isLoading={ addressQuery.isPending || tokenQuery.isPending }
/> />
<TokenBalancesItem <TokenBalancesItem
......
...@@ -6,6 +6,7 @@ import type { AddressesItem } from 'types/api/addresses'; ...@@ -6,6 +6,7 @@ import type { AddressesItem } from 'types/api/addresses';
import config from 'configs/app'; import config from 'configs/app';
import { ZERO } from 'lib/consts'; import { ZERO } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
...@@ -43,7 +44,7 @@ const AddressesListItem = ({ ...@@ -43,7 +44,7 @@ const AddressesListItem = ({
<Tag key={ tag.label } isLoading={ isLoading }>{ tag.display_name }</Tag> <Tag key={ tag.label } isLoading={ isLoading }>{ tag.display_name }</Tag>
)) } )) }
<HStack spacing={ 3 }> <HStack spacing={ 3 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>{ `Balance ${ config.chain.currency.symbol }` }</Skeleton> <Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>{ `Balance ${ currencyUnits.ether }` }</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary"> <Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary">
<span>{ addressBalance.dp(8).toFormat() }</span> <span>{ addressBalance.dp(8).toFormat() }</span>
</Skeleton> </Skeleton>
......
...@@ -4,8 +4,8 @@ import React from 'react'; ...@@ -4,8 +4,8 @@ import React from 'react';
import type { AddressesItem } from 'types/api/addresses'; import type { AddressesItem } from 'types/api/addresses';
import config from 'configs/app';
import { ZERO } from 'lib/consts'; import { ZERO } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
import AddressesTableItem from './AddressesTableItem'; import AddressesTableItem from './AddressesTableItem';
...@@ -27,7 +27,7 @@ const AddressesTable = ({ items, totalSupply, pageStartIndex, top, isLoading }: ...@@ -27,7 +27,7 @@ const AddressesTable = ({ items, totalSupply, pageStartIndex, top, isLoading }:
<Th width="64px">Rank</Th> <Th width="64px">Rank</Th>
<Th width={ hasPercentage ? '30%' : '40%' }>Address</Th> <Th width={ hasPercentage ? '30%' : '40%' }>Address</Th>
<Th width="20%" pl={ 10 }>Public tag</Th> <Th width="20%" pl={ 10 }>Public tag</Th>
<Th width={ hasPercentage ? '20%' : '25%' } isNumeric>{ `Balance ${ config.chain.currency.symbol }` }</Th> <Th width={ hasPercentage ? '20%' : '25%' } isNumeric>{ `Balance ${ currencyUnits.ether }` }</Th>
{ hasPercentage && <Th width="15%" isNumeric>Percentage</Th> } { hasPercentage && <Th width="15%" isNumeric>Percentage</Th> }
<Th width="15%" isNumeric>Txn count</Th> <Th width="15%" isNumeric>Txn count</Th>
</Tr> </Tr>
......
import { test, expect } from '@playwright/experimental-ct-react'; import { test, expect } from '@playwright/experimental-ct-react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react'; import React from 'react';
import type { Block } from 'types/api/block';
import type { ResourceError } from 'lib/api/resources';
import * as blockMock from 'mocks/blocks/block'; import * as blockMock from 'mocks/blocks/block';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import * as configs from 'playwright/utils/configs'; import * as configs from 'playwright/utils/configs';
import BlockDetails from './BlockDetails'; import BlockDetails from './BlockDetails';
import type { BlockQuery } from './useBlockQuery';
const hooksConfig = { const hooksConfig = {
router: { router: {
...@@ -22,7 +19,7 @@ test('regular block +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -22,7 +19,7 @@ test('regular block +@mobile +@dark-mode', async({ mount, page }) => {
const query = { const query = {
data: blockMock.base, data: blockMock.base,
isPending: false, isPending: false,
} as UseQueryResult<Block, ResourceError>; } as BlockQuery;
const component = await mount( const component = await mount(
<TestApp> <TestApp>
...@@ -40,7 +37,7 @@ test('genesis block', async({ mount, page }) => { ...@@ -40,7 +37,7 @@ test('genesis block', async({ mount, page }) => {
const query = { const query = {
data: blockMock.genesis, data: blockMock.genesis,
isPending: false, isPending: false,
} as UseQueryResult<Block, ResourceError>; } as BlockQuery;
const component = await mount( const component = await mount(
<TestApp> <TestApp>
...@@ -63,7 +60,7 @@ customFieldsTest('rootstock custom fields', async({ mount, page }) => { ...@@ -63,7 +60,7 @@ customFieldsTest('rootstock custom fields', async({ mount, page }) => {
const query = { const query = {
data: blockMock.rootstock, data: blockMock.rootstock,
isPending: false, isPending: false,
} as UseQueryResult<Block, ResourceError>; } as BlockQuery;
const component = await mount( const component = await mount(
<TestApp> <TestApp>
......
import { Grid, GridItem, Text, Link, Box, Tooltip, useColorModeValue, Skeleton } from '@chakra-ui/react'; import { Grid, GridItem, Text, Link, Box, Tooltip, useColorModeValue, Skeleton } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { scroller, Element } from 'react-scroll'; import { scroller, Element } from 'react-scroll';
import type { Block } from 'types/api/block';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import getBlockReward from 'lib/block/getBlockReward'; import getBlockReward from 'lib/block/getBlockReward';
import { GWEI, WEI, WEI_IN_GWEI, ZERO } from 'lib/consts'; import { GWEI, WEI, WEI_IN_GWEI, ZERO } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import { space } from 'lib/html-entities'; import { space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { currencyUnits } from 'lib/units';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio'; import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
...@@ -32,8 +28,10 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet'; ...@@ -32,8 +28,10 @@ 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 type { BlockQuery } from './useBlockQuery';
interface Props { interface Props {
query: UseQueryResult<Block, ResourceError>; query: BlockQuery;
} }
const isRollup = config.features.optimisticRollup.isEnabled || config.features.zkEvmRollup.isEnabled; const isRollup = config.features.optimisticRollup.isEnabled || config.features.zkEvmRollup.isEnabled;
...@@ -45,7 +43,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -45,7 +43,7 @@ const BlockDetails = ({ query }: Props) => {
const separatorColor = useColorModeValue('gray.200', 'gray.700'); const separatorColor = useColorModeValue('gray.200', 'gray.700');
const { data, isPlaceholderData, isError, error } = query; const { data, isPlaceholderData } = query;
const handleCutClick = React.useCallback(() => { const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag); setIsExpanded((flag) => !flag);
...@@ -66,18 +64,6 @@ const BlockDetails = ({ query }: Props) => { ...@@ -66,18 +64,6 @@ const BlockDetails = ({ query }: Props) => {
router.push({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, undefined); router.push({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, undefined);
}, [ data, router ]); }, [ data, router ]);
if (isError) {
if (error?.status === 404) {
throw Error('Block not found', { cause: error as unknown as Error });
}
if (error?.status === 422) {
throw Error('Invalid block number', { cause: error as unknown as Error });
}
return <DataFetchAlert/>;
}
if (!data) { if (!data) {
return null; return null;
} }
...@@ -178,14 +164,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -178,14 +164,7 @@ const BlockDetails = ({ query }: Props) => {
hint="Date & time at which block was produced." hint="Date & time at which block was produced."
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/> <DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>
{ dayjs(data.timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('llll') }
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
title="Transactions" title="Transactions"
...@@ -237,7 +216,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -237,7 +216,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
{ totalReward.dividedBy(WEI).toFixed() } { config.chain.currency.symbol } { totalReward.dividedBy(WEI).toFixed() } { currencyUnits.ether }
</Skeleton> </Skeleton>
{ rewardBreakDown } { rewardBreakDown }
</DetailsInfoItem> </DetailsInfoItem>
...@@ -251,7 +230,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -251,7 +230,7 @@ const BlockDetails = ({ query }: Props) => {
// is this text correct for validators? // is this text correct for validators?
hint={ `Amount of distributed reward. ${ capitalize(validatorTitle) }s receive a static block reward + Tx fees + uncle fees` } hint={ `Amount of distributed reward. ${ capitalize(validatorTitle) }s receive a static block reward + Tx fees + uncle fees` }
> >
{ BigNumber(reward).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } { BigNumber(reward).dividedBy(WEI).toFixed() } { currencyUnits.ether }
</DetailsInfoItem> </DetailsInfoItem>
)) ))
} }
...@@ -295,7 +274,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -295,7 +274,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.minimum_gas_price).dividedBy(GWEI).toFormat() } Gwei { BigNumber(data.minimum_gas_price).dividedBy(GWEI).toFormat() } { currencyUnits.gwei }
</Skeleton> </Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
...@@ -309,15 +288,15 @@ const BlockDetails = ({ query }: Props) => { ...@@ -309,15 +288,15 @@ const BlockDetails = ({ query }: Props) => {
<Skeleton isLoaded={ !isPlaceholderData } h="20px" maxW="380px" w="100%"/> <Skeleton isLoaded={ !isPlaceholderData } h="20px" maxW="380px" w="100%"/>
) : ( ) : (
<> <>
<Text>{ BigNumber(data.base_fee_per_gas).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } </Text> <Text>{ BigNumber(data.base_fee_per_gas).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre"> <Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.base_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } Gwei) { space }({ BigNumber(data.base_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text> </Text>
</> </>
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ !config.UI.views.block.hiddenFields?.burnt_fees && ( { !config.UI.views.block.hiddenFields?.burnt_fees && !burntFees.isEqualTo(ZERO) && (
<DetailsInfoItem <DetailsInfoItem
title="Burnt fees" title="Burnt fees"
hint={ hint={
...@@ -329,7 +308,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -329,7 +308,7 @@ const BlockDetails = ({ query }: Props) => {
> >
<IconSvg name="flame" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/> <IconSvg name="flame" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 2 }> <Skeleton isLoaded={ !isPlaceholderData } ml={ 2 }>
{ burntFees.dividedBy(WEI).toFixed() } { config.chain.currency.symbol } { burntFees.dividedBy(WEI).toFixed() } { currencyUnits.ether }
</Skeleton> </Skeleton>
{ !txFees.isEqualTo(ZERO) && ( { !txFees.isEqualTo(ZERO) && (
<Tooltip label="Burnt fees / Txn fees * 100%"> <Tooltip label="Burnt fees / Txn fees * 100%">
...@@ -351,7 +330,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -351,7 +330,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.priority_fee).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } { BigNumber(data.priority_fee).dividedBy(WEI).toFixed() } { currencyUnits.ether }
</Skeleton> </Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
...@@ -446,14 +425,16 @@ const BlockDetails = ({ query }: Props) => { ...@@ -446,14 +425,16 @@ const BlockDetails = ({ query }: Props) => {
<HashStringShortenDynamic hash={ BigNumber(data.difficulty).toFormat() }/> <HashStringShortenDynamic hash={ BigNumber(data.difficulty).toFormat() }/>
</Box> </Box>
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem { data.total_difficulty && (
title="Total difficulty" <DetailsInfoItem
hint="Total difficulty of the chain until this block" title="Total difficulty"
> hint="Total difficulty of the chain until this block"
<Box whiteSpace="nowrap" overflow="hidden"> >
<HashStringShortenDynamic hash={ BigNumber(data.total_difficulty).toFormat() }/> <Box whiteSpace="nowrap" overflow="hidden">
</Box> <HashStringShortenDynamic hash={ BigNumber(data.total_difficulty).toFormat() }/>
</DetailsInfoItem> </Box>
</DetailsInfoItem>
) }
<DetailsInfoItemDivider/> <DetailsInfoItemDivider/>
......
...@@ -3,7 +3,7 @@ import React from 'react'; ...@@ -3,7 +3,7 @@ import React from 'react';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import WithdrawalsListItem from 'ui/withdrawals/WithdrawalsListItem'; import WithdrawalsList from 'ui/withdrawals/WithdrawalsList';
import WithdrawalsTable from 'ui/withdrawals/WithdrawalsTable'; import WithdrawalsTable from 'ui/withdrawals/WithdrawalsTable';
type Props = { type Props = {
...@@ -14,14 +14,11 @@ const BlockWithdrawals = ({ blockWithdrawalsQuery }: Props) => { ...@@ -14,14 +14,11 @@ const BlockWithdrawals = ({ blockWithdrawalsQuery }: Props) => {
const content = blockWithdrawalsQuery.data?.items ? ( const content = blockWithdrawalsQuery.data?.items ? (
<> <>
<Show below="lg" ssr={ false }> <Show below="lg" ssr={ false }>
{ blockWithdrawalsQuery.data.items.map((item, index) => ( <WithdrawalsList
<WithdrawalsListItem items={ blockWithdrawalsQuery.data.items }
key={ item.index + (blockWithdrawalsQuery.isPlaceholderData ? String(index) : '') } isLoading={ blockWithdrawalsQuery.isPlaceholderData }
item={ item } view="block"
view="block" />
isLoading={ blockWithdrawalsQuery.isPlaceholderData }
/>
)) }
</Show> </Show>
<Hide below="lg" ssr={ false }> <Hide below="lg" ssr={ false }>
<WithdrawalsTable <WithdrawalsTable
......
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { Chain, GetBlockReturnType } from 'viem';
import type { Block } from 'types/api/block';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import { retry } from 'lib/api/useQueryClientConfig';
import { SECOND } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import { publicClient } from 'lib/web3/client';
import { BLOCK } from 'stubs/block';
import { GET_BLOCK } from 'stubs/RPC';
import { unknownAddress } from 'ui/shared/address/utils';
type RpcResponseType = GetBlockReturnType<Chain, false, 'latest'> | null;
export type BlockQuery = UseQueryResult<Block, ResourceError<{ status: number }>> & {
isDegradedData: boolean;
};
interface Params {
heightOrHash: string;
}
export default function useBlockQuery({ heightOrHash }: Params): BlockQuery {
const [ isRefetchEnabled, setRefetchEnabled ] = React.useState(false);
const apiQuery = useApiQuery<'block', { status: number }>('block', {
pathParams: { height_or_hash: heightOrHash },
queryOptions: {
enabled: Boolean(heightOrHash),
placeholderData: BLOCK,
refetchOnMount: false,
retry: (failureCount, error) => {
if (isRefetchEnabled) {
return false;
}
return retry(failureCount, error);
},
refetchInterval: (): number | false => {
return isRefetchEnabled ? 15 * SECOND : false;
},
},
});
const rpcQuery = useQuery<RpcResponseType, unknown, Block | null>({
queryKey: [ 'RPC', 'block', { heightOrHash } ],
queryFn: async() => {
const blockParams = heightOrHash.startsWith('0x') ? { blockHash: heightOrHash as `0x${ string }` } : { blockNumber: BigInt(heightOrHash) };
return publicClient.getBlock(blockParams).catch(() => null);
},
select: (block) => {
if (!block) {
return null;
}
return {
height: Number(block.number),
timestamp: dayjs.unix(Number(block.timestamp)).format(),
tx_count: block.transactions.length,
miner: { ...unknownAddress, hash: block.miner },
size: Number(block.size),
hash: block.hash,
parent_hash: block.parentHash,
difficulty: block.difficulty.toString(),
total_difficulty: block.totalDifficulty?.toString() ?? null,
gas_used: block.gasUsed.toString(),
gas_limit: block.gasLimit.toString(),
nonce: block.nonce,
base_fee_per_gas: block.baseFeePerGas?.toString() ?? null,
burnt_fees: null,
priority_fee: null,
extra_data: block.extraData,
state_root: block.stateRoot,
gas_target_percentage: null,
gas_used_percentage: null,
burnt_fees_percentage: null,
type: 'block', // we can't get this type from RPC, so it will always be a regular block
tx_fees: null,
uncles_hashes: block.uncles,
withdrawals_count: block.withdrawals?.length,
};
},
placeholderData: GET_BLOCK,
enabled: apiQuery.isError || apiQuery.errorUpdateCount > 0,
retry: false,
refetchOnMount: false,
});
React.useEffect(() => {
if (apiQuery.isPlaceholderData) {
return;
}
if (apiQuery.isError && apiQuery.errorUpdateCount === 1) {
setRefetchEnabled(true);
} else if (!apiQuery.isError) {
setRefetchEnabled(false);
}
}, [ apiQuery.errorUpdateCount, apiQuery.isError, apiQuery.isPlaceholderData ]);
React.useEffect(() => {
if (!rpcQuery.isPlaceholderData && !rpcQuery.data) {
setRefetchEnabled(false);
}
}, [ rpcQuery.data, rpcQuery.isPlaceholderData ]);
const isRpcQuery = Boolean((apiQuery.isError || apiQuery.isPlaceholderData) && apiQuery.errorUpdateCount > 0 && rpcQuery.data);
const query = isRpcQuery ? rpcQuery as UseQueryResult<Block, ResourceError<{ status: number }>> : apiQuery;
return {
...query,
isDegradedData: isRpcQuery,
};
}
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { Chain, GetBlockReturnType } from 'viem';
import type { BlockTransactionsResponse } from 'types/api/block';
import type { ResourceError } from 'lib/api/resources';
import { retry } from 'lib/api/useQueryClientConfig';
import { SECOND } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import hexToDecimal from 'lib/hexToDecimal';
import { publicClient } from 'lib/web3/client';
import { GET_BLOCK_WITH_TRANSACTIONS } from 'stubs/RPC';
import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
import { unknownAddress } from 'ui/shared/address/utils';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import { emptyPagination } from 'ui/shared/pagination/utils';
import type { BlockQuery } from './useBlockQuery';
type RpcResponseType = GetBlockReturnType<Chain, boolean, 'latest'> | null;
export type BlockTxsQuery = QueryWithPagesResult<'block_txs'> & {
isDegradedData: boolean;
};
interface Params {
heightOrHash: string;
blockQuery: BlockQuery;
tab: string;
}
export default function useBlockTxQuery({ heightOrHash, blockQuery, tab }: Params): BlockTxsQuery {
const [ isRefetchEnabled, setRefetchEnabled ] = React.useState(false);
const apiQuery = useQueryWithPages({
resourceName: 'block_txs',
pathParams: { height_or_hash: heightOrHash },
options: {
enabled: Boolean(tab === 'txs' && !blockQuery.isPlaceholderData && !blockQuery.isDegradedData),
placeholderData: generateListStub<'block_txs'>(TX, 50, { next_page_params: {
block_number: 9004925,
index: 49,
items_count: 50,
} }),
refetchOnMount: false,
retry: (failureCount, error) => {
if (isRefetchEnabled) {
return false;
}
return retry(failureCount, error);
},
refetchInterval: (): number | false => {
return isRefetchEnabled ? 15 * SECOND : false;
},
},
});
const rpcQuery = useQuery<RpcResponseType, unknown, BlockTransactionsResponse | null>({
queryKey: [ 'RPC', 'block_txs', { heightOrHash } ],
queryFn: async() => {
const blockParams = heightOrHash.startsWith('0x') ?
{ blockHash: heightOrHash as `0x${ string }`, includeTransactions: true } :
{ blockNumber: BigInt(heightOrHash), includeTransactions: true };
return publicClient.getBlock(blockParams).catch(() => null);
},
select: (block) => {
if (!block) {
return null;
}
return {
items: block.transactions
.map((tx) => {
if (typeof tx === 'string') {
return;
}
return {
from: { ...unknownAddress, hash: tx.from as string },
to: tx.to ? { ...unknownAddress, hash: tx.to as string } : null,
hash: tx.hash as string,
timestamp: block?.timestamp ? dayjs.unix(Number(block.timestamp)).format() : null,
confirmation_duration: null,
status: undefined,
block: Number(block.number),
value: tx.value.toString(),
gas_price: tx.gasPrice?.toString() ?? null,
base_fee_per_gas: block?.baseFeePerGas?.toString() ?? null,
max_fee_per_gas: tx.maxFeePerGas?.toString() ?? null,
max_priority_fee_per_gas: tx.maxPriorityFeePerGas?.toString() ?? null,
nonce: tx.nonce,
position: tx.transactionIndex,
type: tx.typeHex ? hexToDecimal(tx.typeHex) : null,
raw_input: tx.input,
gas_used: null,
gas_limit: tx.gas.toString(),
confirmations: 0,
fee: {
value: null,
type: 'actual',
},
created_contract: null,
result: '',
priority_fee: null,
tx_burnt_fee: null,
revert_reason: null,
decoded_input: null,
has_error_in_internal_txs: null,
token_transfers: null,
token_transfers_overflow: false,
exchange_rate: null,
method: null,
tx_types: [],
tx_tag: null,
actions: [],
};
})
.filter(Boolean),
next_page_params: null,
};
},
placeholderData: GET_BLOCK_WITH_TRANSACTIONS,
enabled: tab === 'txs' && (blockQuery.isDegradedData || apiQuery.isError || apiQuery.errorUpdateCount > 0),
retry: false,
refetchOnMount: false,
});
React.useEffect(() => {
if (apiQuery.isPlaceholderData) {
return;
}
if (apiQuery.isError && apiQuery.errorUpdateCount === 1) {
setRefetchEnabled(true);
} else if (!apiQuery.isError) {
setRefetchEnabled(false);
}
}, [ apiQuery.errorUpdateCount, apiQuery.isError, apiQuery.isPlaceholderData ]);
React.useEffect(() => {
if (!rpcQuery.isPlaceholderData && !rpcQuery.data) {
setRefetchEnabled(false);
}
}, [ rpcQuery.data, rpcQuery.isPlaceholderData ]);
const isRpcQuery = Boolean((
blockQuery.isDegradedData ||
((apiQuery.isError || apiQuery.isPlaceholderData) && apiQuery.errorUpdateCount > 0)
) && rpcQuery.data);
const rpcQueryWithPages: QueryWithPagesResult<'block_txs'> = React.useMemo(() => {
return {
...rpcQuery as UseQueryResult<BlockTransactionsResponse, ResourceError>,
pagination: emptyPagination,
onFilterChange: () => {},
onSortingChange: () => {},
};
}, [ rpcQuery ]);
const query = isRpcQuery ? rpcQueryWithPages : apiQuery;
return {
...query,
isDegradedData: isRpcQuery,
};
}
import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { Chain, GetBlockReturnType } from 'viem';
import type { BlockWithdrawalsResponse } from 'types/api/block';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import { retry } from 'lib/api/useQueryClientConfig';
import { SECOND } from 'lib/consts';
import hexToDecimal from 'lib/hexToDecimal';
import { publicClient } from 'lib/web3/client';
import { GET_BLOCK } from 'stubs/RPC';
import { generateListStub } from 'stubs/utils';
import { WITHDRAWAL } from 'stubs/withdrawals';
import { unknownAddress } from 'ui/shared/address/utils';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import { emptyPagination } from 'ui/shared/pagination/utils';
import type { BlockQuery } from './useBlockQuery';
type RpcResponseType = GetBlockReturnType<Chain, false, 'latest'> | null;
export type BlockWithdrawalsQuery = QueryWithPagesResult<'block_withdrawals'> & {
isDegradedData: boolean;
};
interface Params {
heightOrHash: string;
blockQuery: BlockQuery;
tab: string;
}
export default function useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab }: Params): BlockWithdrawalsQuery {
const [ isRefetchEnabled, setRefetchEnabled ] = React.useState(false);
const apiQuery = useQueryWithPages({
resourceName: 'block_withdrawals',
pathParams: { height_or_hash: heightOrHash },
options: {
enabled:
tab === 'withdrawals' &&
config.features.beaconChain.isEnabled &&
!blockQuery.isPlaceholderData && !blockQuery.isDegradedData,
placeholderData: generateListStub<'block_withdrawals'>(WITHDRAWAL, 50, { next_page_params: {
index: 5,
items_count: 50,
} }),
refetchOnMount: false,
retry: (failureCount, error) => {
if (isRefetchEnabled) {
return false;
}
return retry(failureCount, error);
},
refetchInterval: (): number | false => {
return isRefetchEnabled ? 15 * SECOND : false;
},
},
});
const rpcQuery = useQuery<RpcResponseType, unknown, BlockWithdrawalsResponse | null>({
queryKey: [ 'RPC', 'block', { heightOrHash } ],
queryFn: async() => {
const blockParams = heightOrHash.startsWith('0x') ? { blockHash: heightOrHash as `0x${ string }` } : { blockNumber: BigInt(heightOrHash) };
return publicClient.getBlock(blockParams).catch(() => null);
},
select: (block) => {
if (!block) {
return null;
}
return {
items: block.withdrawals
?.map((withdrawal) => {
return {
amount: hexToDecimal(withdrawal.amount).toString(),
index: hexToDecimal(withdrawal.index),
validator_index: hexToDecimal(withdrawal.validatorIndex),
receiver: { ...unknownAddress, hash: withdrawal.address },
};
})
.sort((a, b) => b.index - a.index) ?? [],
next_page_params: null,
};
},
placeholderData: GET_BLOCK,
enabled:
tab === 'withdrawals' &&
config.features.beaconChain.isEnabled &&
(blockQuery.isDegradedData || apiQuery.isError || apiQuery.errorUpdateCount > 0),
retry: false,
refetchOnMount: false,
});
React.useEffect(() => {
if (apiQuery.isPlaceholderData) {
return;
}
if (apiQuery.isError && apiQuery.errorUpdateCount === 1) {
setRefetchEnabled(true);
} else if (!apiQuery.isError) {
setRefetchEnabled(false);
}
}, [ apiQuery.errorUpdateCount, apiQuery.isError, apiQuery.isPlaceholderData ]);
React.useEffect(() => {
if (!rpcQuery.isPlaceholderData && !rpcQuery.data) {
setRefetchEnabled(false);
}
}, [ rpcQuery.data, rpcQuery.isPlaceholderData ]);
const isRpcQuery = Boolean((
blockQuery.isDegradedData ||
((apiQuery.isError || apiQuery.isPlaceholderData) && apiQuery.errorUpdateCount > 0)
) && rpcQuery.data);
const rpcQueryWithPages: QueryWithPagesResult<'block_withdrawals'> = React.useMemo(() => {
return {
...rpcQuery as UseQueryResult<BlockWithdrawalsResponse, ResourceError>,
pagination: emptyPagination,
onFilterChange: () => {},
onSortingChange: () => {},
};
}, [ rpcQuery ]);
const query = isRpcQuery ? rpcQueryWithPages : apiQuery;
return {
...query,
isDegradedData: isRpcQuery,
};
}
...@@ -11,6 +11,7 @@ import config from 'configs/app'; ...@@ -11,6 +11,7 @@ import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward'; import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import BlockTimestamp from 'ui/blocks/BlockTimestamp'; import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
...@@ -94,7 +95,7 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => { ...@@ -94,7 +95,7 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
</Box> </Box>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && ( { !isRollup && !config.UI.views.block.hiddenFields?.total_reward && (
<Flex columnGap={ 2 }> <Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>Reward { config.chain.currency.symbol }</Text> <Text fontWeight={ 500 }>Reward { currencyUnits.ether }</Text>
<Skeleton isLoaded={ !isLoading } display="inline-block" color="text_secondary"> <Skeleton isLoaded={ !isLoading } display="inline-block" color="text_secondary">
<span>{ totalReward.toFixed() }</span> <span>{ totalReward.toFixed() }</span>
</Skeleton> </Skeleton>
......
...@@ -8,6 +8,7 @@ import type { Block } from 'types/api/block'; ...@@ -8,6 +8,7 @@ import type { Block } from 'types/api/block';
import config from 'configs/app'; import config from 'configs/app';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import BlocksTableItem from 'ui/blocks/BlocksTableItem'; import BlocksTableItem from 'ui/blocks/BlocksTableItem';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice'; import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
...@@ -49,9 +50,9 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum ...@@ -49,9 +50,9 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum
<Th width="64px" isNumeric>Txn</Th> <Th width="64px" isNumeric>Txn</Th>
<Th width={ `${ GAS_COL_WEIGHT / widthBase * 100 }%` }>Gas used</Th> <Th width={ `${ GAS_COL_WEIGHT / widthBase * 100 }%` }>Gas used</Th>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && { !isRollup && !config.UI.views.block.hiddenFields?.total_reward &&
<Th width={ `${ REWARD_COL_WEIGHT / widthBase * 100 }%` }>Reward { config.chain.currency.symbol }</Th> } <Th width={ `${ REWARD_COL_WEIGHT / widthBase * 100 }%` }>Reward { currencyUnits.ether }</Th> }
{ !isRollup && !config.UI.views.block.hiddenFields?.burnt_fees && { !isRollup && !config.UI.views.block.hiddenFields?.burnt_fees &&
<Th width={ `${ FEES_COL_WEIGHT / widthBase * 100 }%` }>Burnt fees { config.chain.currency.symbol }</Th> } <Th width={ `${ FEES_COL_WEIGHT / widthBase * 100 }%` }>Burnt fees { currencyUnits.ether }</Th> }
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
......
...@@ -13,6 +13,7 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -13,6 +13,7 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
...@@ -88,7 +89,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { ...@@ -88,7 +89,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
<Flex flexDir="column"> <Flex flexDir="column">
{ !config.UI.views.tx.hiddenFields?.value && ( { !config.UI.views.tx.hiddenFields?.value && (
<Skeleton isLoaded={ !isLoading } my="3px"> <Skeleton isLoaded={ !isLoading } my="3px">
<Text as="span" whiteSpace="pre">{ config.chain.currency.symbol } </Text> <Text as="span" whiteSpace="pre">{ currencyUnits.ether } </Text>
<Text as="span" variant="secondary">{ getValueWithUnit(tx.value).dp(5).toFormat() }</Text> <Text as="span" variant="secondary">{ getValueWithUnit(tx.value).dp(5).toFormat() }</Text>
</Skeleton> </Skeleton>
) } ) }
......
...@@ -12,6 +12,7 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -12,6 +12,7 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus'; import TxStatus from 'ui/shared/statusTag/TxStatus';
...@@ -75,13 +76,13 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => { ...@@ -75,13 +76,13 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
/> />
{ !config.UI.views.tx.hiddenFields?.value && ( { !config.UI.views.tx.hiddenFields?.value && (
<Skeleton isLoaded={ !isLoading } mb={ 2 } fontSize="sm" w="fit-content"> <Skeleton isLoaded={ !isLoading } mb={ 2 } fontSize="sm" w="fit-content">
<Text as="span">Value { config.chain.currency.symbol } </Text> <Text as="span">Value { currencyUnits.ether } </Text>
<Text as="span" variant="secondary">{ getValueWithUnit(tx.value).dp(5).toFormat() }</Text> <Text as="span" variant="secondary">{ getValueWithUnit(tx.value).dp(5).toFormat() }</Text>
</Skeleton> </Skeleton>
) } ) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && ( { !config.UI.views.tx.hiddenFields?.tx_fee && (
<Skeleton isLoaded={ !isLoading } fontSize="sm" w="fit-content" display="flex" whiteSpace="pre"> <Skeleton isLoaded={ !isLoading } fontSize="sm" w="fit-content" display="flex" whiteSpace="pre">
<Text as="span">Fee { !config.UI.views.tx.hiddenFields?.fee_currency ? `${ config.chain.currency.symbol } ` : '' }</Text> <Text as="span">Fee { !config.UI.views.tx.hiddenFields?.fee_currency ? `${ currencyUnits.ether } ` : '' }</Text>
{ tx.stability_fee ? ( { tx.stability_fee ? (
<TxFeeStability data={ tx.stability_fee } accuracy={ 5 } color="text_secondary" hideUsd/> <TxFeeStability data={ tx.stability_fee } accuracy={ 5 } color="text_secondary" hideUsd/>
) : ( ) : (
......
...@@ -7,6 +7,7 @@ import { route } from 'nextjs-routes'; ...@@ -7,6 +7,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS } from 'stubs/stats';
import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent'; import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent';
...@@ -59,7 +60,7 @@ const Stats = () => { ...@@ -59,7 +60,7 @@ const Stats = () => {
} }
if (data.gas_prices?.average?.price) { if (data.gas_prices?.average?.price) {
return `${ data.gas_prices.average.price.toLocaleString() } Gwei`; return `${ data.gas_prices.average.price.toLocaleString() } ${ currencyUnits.gwei }`;
} }
return 'N/A'; return 'N/A';
......
...@@ -34,7 +34,7 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats ...@@ -34,7 +34,7 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats
return null; return null;
} }
if (stats.isPending) { if (stats.isPlaceholderData) {
return ( return (
<Skeleton <Skeleton
h={ 3 } h={ 3 }
...@@ -46,7 +46,7 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats ...@@ -46,7 +46,7 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats
); );
} }
if (stats.isError) { if (!stats.data) {
return <Text variant="secondary" fontWeight={ 400 }>no data</Text>; return <Text variant="secondary" fontWeight={ 400 }>no data</Text>;
} }
......
...@@ -52,11 +52,11 @@ const ChainIndicators = () => { ...@@ -52,11 +52,11 @@ const ChainIndicators = () => {
} }
const valueTitle = (() => { const valueTitle = (() => {
if (statsQueryResult.isPending) { if (statsQueryResult.isPlaceholderData) {
return <Skeleton h="48px" w="215px" mt={ 3 } mb={ 4 }/>; return <Skeleton h="48px" w="215px" mt={ 3 } mb={ 4 }/>;
} }
if (statsQueryResult.isError) { if (!statsQueryResult.data) {
return <Text mt={ 3 } mb={ 4 }>There is no data</Text>; return <Text mt={ 3 } mb={ 4 }>There is no data</Text>;
} }
......
...@@ -128,8 +128,7 @@ const MarketplaceAppCard = ({ ...@@ -128,8 +128,7 @@ const MarketplaceAppCard = ({
fontSize={{ base: 'sm', sm: 'lg' }} fontSize={{ base: 'sm', sm: 'lg' }}
fontWeight="semibold" fontWeight="semibold"
fontFamily="heading" fontFamily="heading"
display={ isExperiment ? 'flex' : 'inline-block' } display="inline-block"
alignItems={ isExperiment ? 'center' : undefined }
> >
<MarketplaceAppCardLink <MarketplaceAppCardLink
id={ id } id={ id }
...@@ -148,11 +147,12 @@ const MarketplaceAppCard = ({ ...@@ -148,11 +147,12 @@ const MarketplaceAppCard = ({
> >
<IconSvg <IconSvg
name={ integrationIcon } name={ integrationIcon }
marginLeft={ 2 }
boxSize={ 5 } boxSize={ 5 }
color={ integrationIconColor } color={ integrationIconColor }
position="relative" position="relative"
cursor="pointer" cursor="pointer"
verticalAlign="middle"
marginBottom={ 1 }
/> />
</Tooltip> </Tooltip>
) } ) }
......
...@@ -13,12 +13,12 @@ type Props = { ...@@ -13,12 +13,12 @@ type Props = {
const MarketplaceAppCardLink = ({ url, external, id, title, onClick }: Props) => { const MarketplaceAppCardLink = ({ url, external, id, title, onClick }: Props) => {
return external ? ( return external ? (
<LinkOverlay href={ url } isExternal={ true }> <LinkOverlay href={ url } isExternal={ true } marginRight={ 2 }>
{ title } { title }
</LinkOverlay> </LinkOverlay>
) : ( ) : (
<NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior> <NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior>
<LinkOverlay onClick={ onClick }> <LinkOverlay onClick={ onClick } marginRight={ 2 }>
{ title } { title }
</LinkOverlay> </LinkOverlay>
</NextLink> </NextLink>
......
...@@ -11,6 +11,7 @@ import useContractTabs from 'lib/hooks/useContractTabs'; ...@@ -11,6 +11,7 @@ import useContractTabs from 'lib/hooks/useContractTabs';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_INFO, ADDRESS_TABS_COUNTERS } from 'stubs/address'; import { ADDRESS_INFO, ADDRESS_TABS_COUNTERS } from 'stubs/address';
import { USER_OPS_ACCOUNT } from 'stubs/userOps';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated'; import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance'; import AddressCoinBalance from 'ui/address/AddressCoinBalance';
import AddressContract from 'ui/address/AddressContract'; import AddressContract from 'ui/address/AddressContract';
...@@ -20,6 +21,7 @@ import AddressLogs from 'ui/address/AddressLogs'; ...@@ -20,6 +21,7 @@ import AddressLogs from 'ui/address/AddressLogs';
import AddressTokens from 'ui/address/AddressTokens'; import AddressTokens from 'ui/address/AddressTokens';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers'; import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs'; import AddressTxs from 'ui/address/AddressTxs';
import AddressUserOps from 'ui/address/AddressUserOps';
import AddressWithdrawals from 'ui/address/AddressWithdrawals'; import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressQrCode from 'ui/address/details/AddressQrCode';
...@@ -62,6 +64,14 @@ const AddressPageContent = () => { ...@@ -62,6 +64,14 @@ const AddressPageContent = () => {
}, },
}); });
const userOpsAccountQuery = useApiQuery('user_ops_account', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: USER_OPS_ACCOUNT,
},
});
const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined); const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined);
const contractTabs = useContractTabs(addressQuery.data); const contractTabs = useContractTabs(addressQuery.data);
...@@ -74,6 +84,14 @@ const AddressPageContent = () => { ...@@ -74,6 +84,14 @@ const AddressPageContent = () => {
count: addressTabsCountersQuery.data?.transactions_count, count: addressTabsCountersQuery.data?.transactions_count,
component: <AddressTxs scrollRef={ tabsScrollRef }/>, component: <AddressTxs scrollRef={ tabsScrollRef }/>,
}, },
config.features.userOps.isEnabled && Boolean(userOpsAccountQuery.data?.total_ops) ?
{
id: 'user_ops',
title: 'User operations',
count: userOpsAccountQuery.data?.total_ops,
component: <AddressUserOps/>,
} :
undefined,
config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.withdrawals_count ? config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.withdrawals_count ?
{ {
id: 'withdrawals', id: 'withdrawals',
...@@ -140,7 +158,7 @@ const AddressPageContent = () => { ...@@ -140,7 +158,7 @@ const AddressPageContent = () => {
subTabs: contractTabs.map(tab => tab.id), subTabs: contractTabs.map(tab => tab.id),
} : undefined, } : undefined,
].filter(Boolean); ].filter(Boolean);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data ]); }, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data ]);
const tags = ( const tags = (
<EntityTags <EntityTags
...@@ -151,6 +169,7 @@ const AddressPageContent = () => { ...@@ -151,6 +169,7 @@ const AddressPageContent = () => {
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined, addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined, addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined, isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined,
userOpsAccountQuery.data ? { label: 'user_ops_acc', display_name: 'Smart contract wallet' } : undefined,
] } ] }
/> />
); );
...@@ -222,7 +241,10 @@ const AddressPageContent = () => { ...@@ -222,7 +241,10 @@ const AddressPageContent = () => {
<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>
{ (addressQuery.isPlaceholderData || addressTabsCountersQuery.isPlaceholderData) ? <TabsSkeleton tabs={ tabs }/> : content } { (addressQuery.isPlaceholderData || addressTabsCountersQuery.isPlaceholderData || userOpsAccountQuery.isPlaceholderData) ?
<TabsSkeleton tabs={ tabs }/> :
content
}
</> </>
); );
}; };
......
...@@ -6,22 +6,22 @@ import type { PaginationParams } from 'ui/shared/pagination/types'; ...@@ -6,22 +6,22 @@ import type { PaginationParams } from 'ui/shared/pagination/types';
import type { RoutedTab } from 'ui/shared/Tabs/types'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { BLOCK } from 'stubs/block';
import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
import { WITHDRAWAL } from 'stubs/withdrawals';
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 useBlockQuery from 'ui/block/useBlockQuery';
import useBlockTxQuery from 'ui/block/useBlockTxQuery';
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 AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination'; import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
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 TxsWithFrontendSorting from 'ui/txs/TxsWithFrontendSorting'; import TxsWithFrontendSorting from 'ui/txs/TxsWithFrontendSorting';
...@@ -39,53 +39,42 @@ const BlockPageContent = () => { ...@@ -39,53 +39,42 @@ const BlockPageContent = () => {
const heightOrHash = getQueryParamString(router.query.height_or_hash); const heightOrHash = getQueryParamString(router.query.height_or_hash);
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
const blockQuery = useApiQuery('block', { const blockQuery = useBlockQuery({ heightOrHash });
pathParams: { height_or_hash: heightOrHash }, const blockTxsQuery = useBlockTxQuery({ heightOrHash, blockQuery, tab });
queryOptions: { const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab });
enabled: Boolean(heightOrHash),
placeholderData: BLOCK,
},
});
const blockTxsQuery = useQueryWithPages({
resourceName: 'block_txs',
pathParams: { height_or_hash: heightOrHash },
options: {
enabled: Boolean(!blockQuery.isPlaceholderData && blockQuery.data?.height && tab === 'txs'),
placeholderData: generateListStub<'block_txs'>(TX, 50, { next_page_params: {
block_number: 9004925,
index: 49,
items_count: 50,
} }),
},
});
const blockWithdrawalsQuery = useQueryWithPages({
resourceName: 'block_withdrawals',
pathParams: { height_or_hash: heightOrHash },
options: {
enabled: Boolean(!blockQuery.isPlaceholderData && blockQuery.data?.height && config.features.beaconChain.isEnabled && tab === 'withdrawals'),
placeholderData: generateListStub<'block_withdrawals'>(WITHDRAWAL, 50, { next_page_params: {
index: 5,
items_count: 50,
} }),
},
});
if (!heightOrHash) {
throw new Error('Block not found', { cause: { status: 404 } });
}
if (blockQuery.isError) {
throw new Error(undefined, { cause: blockQuery.error });
}
const tabs: Array<RoutedTab> = React.useMemo(() => ([ const tabs: Array<RoutedTab> = React.useMemo(() => ([
{ id: 'index', title: 'Details', component: <BlockDetails query={ blockQuery }/> }, {
{ id: 'txs', title: 'Transactions', component: <TxsWithFrontendSorting query={ blockTxsQuery } showBlockInfo={ false } showSocketInfo={ false }/> }, id: 'index',
title: 'Details',
component: (
<>
{ blockQuery.isDegradedData && <ServiceDegradationWarning isLoading={ blockQuery.isPlaceholderData } mb={ 6 }/> }
<BlockDetails query={ blockQuery }/>
</>
),
},
{
id: 'txs',
title: 'Transactions',
component: (
<>
{ blockTxsQuery.isDegradedData && <ServiceDegradationWarning isLoading={ blockTxsQuery.isPlaceholderData } mb={ 6 }/> }
<TxsWithFrontendSorting query={ blockTxsQuery } showBlockInfo={ false } showSocketInfo={ false }/>
</>
),
},
config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.withdrawals_count) ? config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.withdrawals_count) ?
{ id: 'withdrawals', title: 'Withdrawals', component: <BlockWithdrawals blockWithdrawalsQuery={ blockWithdrawalsQuery }/> } : {
null, id: 'withdrawals',
title: 'Withdrawals',
component: (
<>
{ blockWithdrawalsQuery.isDegradedData && <ServiceDegradationWarning isLoading={ blockWithdrawalsQuery.isPlaceholderData } mb={ 6 }/> }
<BlockWithdrawals blockWithdrawalsQuery={ blockWithdrawalsQuery }/>
</>
),
} : null,
].filter(Boolean)), [ blockQuery, blockTxsQuery, blockWithdrawalsQuery ]); ].filter(Boolean)), [ blockQuery, blockTxsQuery, blockWithdrawalsQuery ]);
const hasPagination = !isMobile && ( const hasPagination = !isMobile && (
...@@ -113,6 +102,9 @@ const BlockPageContent = () => { ...@@ -113,6 +102,9 @@ const BlockPageContent = () => {
}; };
}, [ appProps.referrer ]); }, [ appProps.referrer ]);
throwOnAbsentParamError(heightOrHash);
throwOnResourceLoadError(blockQuery);
const title = (() => { const title = (() => {
switch (blockQuery.data?.type) { switch (blockQuery.data?.type) {
case 'reorg': case 'reorg':
......
...@@ -5,6 +5,7 @@ import type { SmartContractVerificationMethod } from 'types/api/contract'; ...@@ -5,6 +5,7 @@ import type { SmartContractVerificationMethod } from 'types/api/contract';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import ContractVerificationForm from 'ui/contractVerification/ContractVerificationForm'; import ContractVerificationForm from 'ui/contractVerification/ContractVerificationForm';
import useFormConfigQuery from 'ui/contractVerification/useFormConfigQuery'; import useFormConfigQuery from 'ui/contractVerification/useFormConfigQuery';
...@@ -27,9 +28,7 @@ const ContractVerificationForAddress = () => { ...@@ -27,9 +28,7 @@ const ContractVerificationForAddress = () => {
}, },
}); });
if (contractQuery.isError && contractQuery.error.status === 404) { throwOnResourceLoadError(contractQuery);
throw Error('Not found', { cause: contractQuery.error as unknown as Error });
}
const configQuery = useFormConfigQuery(Boolean(hash)); const configQuery = useFormConfigQuery(Boolean(hash));
......
...@@ -8,11 +8,12 @@ import type { CsvExportParams } from 'types/client/address'; ...@@ -8,11 +8,12 @@ import type { CsvExportParams } from 'types/client/address';
import type { ResourceName } from 'lib/api/resources'; import type { ResourceName } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities'; import { nbsp } from 'lib/html-entities';
import CsvExportForm from 'ui/csvExport/CsvExportForm'; import CsvExportForm from 'ui/csvExport/CsvExportForm';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -62,7 +63,8 @@ const CsvExport = () => { ...@@ -62,7 +63,8 @@ const CsvExport = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const addressHash = router.query.address?.toString() || ''; const addressHash = router.query.address?.toString() || '';
const exportType = router.query.type?.toString() || ''; const exportTypeParam = router.query.type?.toString() || '';
const exportType = isCorrectExportType(exportTypeParam) ? EXPORT_TYPES[exportTypeParam] : null;
const filterTypeFromQuery = router.query.filterType?.toString() || null; const filterTypeFromQuery = router.query.filterType?.toString() || null;
const filterValueFromQuery = router.query.filterValue?.toString(); const filterValueFromQuery = router.query.filterValue?.toString();
...@@ -86,17 +88,20 @@ const CsvExport = () => { ...@@ -86,17 +88,20 @@ const CsvExport = () => {
}; };
}, [ appProps.referrer ]); }, [ appProps.referrer ]);
if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) { throwOnAbsentParamError(addressHash);
throw Error('Not found', { cause: { status: 404 } }); throwOnAbsentParamError(exportType);
if (!exportType) {
return null;
} }
const filterType = filterTypeFromQuery === EXPORT_TYPES[exportType].filterType ? filterTypeFromQuery : null; const filterType = filterTypeFromQuery === exportType.filterType ? filterTypeFromQuery : null;
const filterValue = (() => { const filterValue = (() => {
if (!filterType || !filterValueFromQuery) { if (!filterType || !filterValueFromQuery) {
return null; return null;
} }
if (EXPORT_TYPES[exportType].filterValues && !EXPORT_TYPES[exportType].filterValues?.includes(filterValueFromQuery)) { if (exportType.filterValues && !exportType.filterValues?.includes(filterValueFromQuery)) {
return null; return null;
} }
...@@ -104,9 +109,7 @@ const CsvExport = () => { ...@@ -104,9 +109,7 @@ const CsvExport = () => {
})(); })();
const content = (() => { const content = (() => {
if (addressQuery.isError) { throwOnResourceLoadError(addressQuery);
return <DataFetchAlert/>;
}
if (addressQuery.isPending) { if (addressQuery.isPending) {
return <ContentLoader/>; return <ContentLoader/>;
...@@ -115,10 +118,10 @@ const CsvExport = () => { ...@@ -115,10 +118,10 @@ const CsvExport = () => {
return ( return (
<CsvExportForm <CsvExportForm
hash={ addressHash } hash={ addressHash }
resource={ EXPORT_TYPES[exportType].resource } resource={ exportType.resource }
filterType={ filterType } filterType={ filterType }
filterValue={ filterValue } filterValue={ filterValue }
fileNameTemplate={ EXPORT_TYPES[exportType].fileNameTemplate } fileNameTemplate={ exportType.fileNameTemplate }
/> />
); );
})(); })();
...@@ -130,7 +133,7 @@ const CsvExport = () => { ...@@ -130,7 +133,7 @@ const CsvExport = () => {
backLink={ backLink } backLink={ backLink }
/> />
<Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap"> <Flex mb={ 10 } whiteSpace="pre-wrap" flexWrap="wrap">
<span>Export { EXPORT_TYPES[exportType].text } for address </span> <span>Export { exportType.text } for address </span>
<AddressEntity <AddressEntity
address={{ hash: addressHash, is_contract: true, implementation_name: null }} address={{ hash: addressHash, is_contract: true, implementation_name: null }}
truncation={ isMobile ? 'constant' : 'dynamic' } truncation={ isMobile ? 'constant' : 'dynamic' }
...@@ -139,7 +142,7 @@ const CsvExport = () => { ...@@ -139,7 +142,7 @@ const CsvExport = () => {
<span>{ nbsp }</span> <span>{ nbsp }</span>
{ filterType && filterValue && <span>with applied filter by { filterType } ({ filterValue }) </span> } { filterType && filterValue && <span>with applied filter by { filterType } ({ filterValue }) </span> }
<span>to CSV file. </span> <span>to CSV file. </span>
<span>Exports are limited to the last 10K { EXPORT_TYPES[exportType].text }.</span> <span>Exports are limited to the last 10K { exportType.text }.</span>
</Flex> </Flex>
{ content } { content }
</> </>
......
...@@ -5,6 +5,7 @@ import { MarketplaceCategory } from 'types/client/marketplace'; ...@@ -5,6 +5,7 @@ import { MarketplaceCategory } from 'types/client/marketplace';
import type { TabItem } from 'ui/shared/Tabs/types'; import type { TabItem } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useFeatureValue from 'lib/growthbook/useFeatureValue'; import useFeatureValue from 'lib/growthbook/useFeatureValue';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal'; import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceCategoriesMenu from 'ui/marketplace/MarketplaceCategoriesMenu'; import MarketplaceCategoriesMenu from 'ui/marketplace/MarketplaceCategoriesMenu';
...@@ -76,9 +77,7 @@ const Marketplace = () => { ...@@ -76,9 +77,7 @@ const Marketplace = () => {
onCategoryChange(categoryTabs[index].id); onCategoryChange(categoryTabs[index].id);
}, [ categoryTabs, onCategoryChange ]); }, [ categoryTabs, onCategoryChange ]);
if (isError) { throwOnResourceLoadError(isError && error ? { isError, error } : { isError: false, error: null });
throw new Error('Unable to get apps list', { cause: error });
}
if (!feature.isEnabled) { if (!feature.isEnabled) {
return null; return null;
......
...@@ -10,6 +10,7 @@ import { route } from 'nextjs-routes'; ...@@ -10,6 +10,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useApiFetch from 'lib/hooks/useFetch'; import useApiFetch from 'lib/hooks/useFetch';
import * as metadata from 'lib/metadata'; import * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
...@@ -100,7 +101,7 @@ const MarketplaceApp = () => { ...@@ -100,7 +101,7 @@ const MarketplaceApp = () => {
const router = useRouter(); const router = useRouter();
const id = getQueryParamString(router.query.id); const id = getQueryParamString(router.query.id);
const { isPending, isError, error, data } = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({ const query = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
queryKey: [ 'marketplace-apps', id ], queryKey: [ 'marketplace-apps', id ],
queryFn: async() => { queryFn: async() => {
const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(configUrl, undefined, { resource: 'marketplace-apps' }); const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(configUrl, undefined, { resource: 'marketplace-apps' });
...@@ -116,6 +117,7 @@ const MarketplaceApp = () => { ...@@ -116,6 +117,7 @@ const MarketplaceApp = () => {
}, },
enabled: feature.isEnabled, enabled: feature.isEnabled,
}); });
const { data, isPending } = query;
useEffect(() => { useEffect(() => {
if (data) { if (data) {
...@@ -126,9 +128,7 @@ const MarketplaceApp = () => { ...@@ -126,9 +128,7 @@ const MarketplaceApp = () => {
} }
}, [ data ]); }, [ data ]);
if (isError) { throwOnResourceLoadError(query);
throw new Error('Unable to load app', { cause: error });
}
return ( return (
<DappscoutIframeProvider <DappscoutIframeProvider
......
...@@ -8,6 +8,7 @@ import { route } from 'nextjs-routes'; ...@@ -8,6 +8,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { ENS_DOMAIN } from 'stubs/ENS'; import { ENS_DOMAIN } from 'stubs/ENS';
...@@ -42,9 +43,7 @@ const NameDomain = () => { ...@@ -42,9 +43,7 @@ const NameDomain = () => {
const tabIndex = useTabIndexFromQuery(tabs); const tabIndex = useTabIndexFromQuery(tabs);
if (infoQuery.isError) { throwOnResourceLoadError(infoQuery);
throw new Error(undefined, { cause: infoQuery.error });
}
const isLoading = infoQuery.isPlaceholderData; const isLoading = infoQuery.isPlaceholderData;
...@@ -81,7 +80,7 @@ const NameDomain = () => { ...@@ -81,7 +80,7 @@ const NameDomain = () => {
return ( return (
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
<PageTitle title="ENS Domain details" secondRow={ titleSecondRow }/> <PageTitle title="Name details" secondRow={ titleSecondRow }/>
{ infoQuery.isPlaceholderData ? ( { infoQuery.isPlaceholderData ? (
<> <>
<TabsSkeleton tabs={ tabs } mt={ 6 }/> <TabsSkeleton tabs={ tabs } mt={ 6 }/>
......
...@@ -193,7 +193,7 @@ const NameDomains = () => { ...@@ -193,7 +193,7 @@ const NameDomains = () => {
return ( return (
<> <>
<PageTitle title="ENS domains lookup" withTextAd/> <PageTitle title="Name services lookup" withTextAd/>
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
items={ data?.items } items={ data?.items }
......
...@@ -8,6 +8,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; ...@@ -8,6 +8,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app'; import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import SearchResults from './SearchResults'; import SearchResults from './SearchResults';
...@@ -157,6 +158,36 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -157,6 +158,36 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(component.locator('main')).toHaveScreenshot(); await expect(component.locator('main')).toHaveScreenshot();
}); });
const testWithUserOps = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.userOp1.user_operation_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.userOp1.user_operation_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.userOp1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test.describe('with apps', () => { test.describe('with apps', () => {
const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || ''; const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || '';
const extendedTest = test.extend({ const extendedTest = test.extend({
......
...@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; ...@@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import React from 'react'; import React from 'react';
import config from 'configs/app';
import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps'; import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps';
import SearchResultListItem from 'ui/searchResults/SearchResultListItem'; import SearchResultListItem from 'ui/searchResults/SearchResultListItem';
import SearchResultsInput from 'ui/searchResults/SearchResultsInput'; import SearchResultsInput from 'ui/searchResults/SearchResultsInput';
...@@ -52,6 +53,12 @@ const SearchResultsPageContent = () => { ...@@ -52,6 +53,12 @@ const SearchResultsPageContent = () => {
router.replace({ pathname: '/tx/[hash]', query: { hash: redirectCheckQuery.data.parameter } }); router.replace({ pathname: '/tx/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return; return;
} }
case 'user_operation': {
if (config.features.userOps.isEnabled) {
router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return;
}
}
} }
} }
...@@ -62,12 +69,19 @@ const SearchResultsPageContent = () => { ...@@ -62,12 +69,19 @@ const SearchResultsPageContent = () => {
event.preventDefault(); event.preventDefault();
}, [ ]); }, [ ]);
const displayedItems = (data?.items || []).filter((item) => {
if (!config.features.userOps.isEnabled && item.type === 'user_operation') {
return false;
}
return true;
});
const content = (() => { const content = (() => {
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
const hasData = data?.items.length || (pagination.page === 1 && marketplaceApps.displayedApps.length); const hasData = displayedItems.length || (pagination.page === 1 && marketplaceApps.displayedApps.length);
if (!hasData) { if (!hasData) {
return null; return null;
...@@ -83,7 +97,7 @@ const SearchResultsPageContent = () => { ...@@ -83,7 +97,7 @@ const SearchResultsPageContent = () => {
searchTerm={ debouncedSearchTerm } searchTerm={ debouncedSearchTerm }
/> />
)) } )) }
{ data && data.items.map((item, index) => ( { displayedItems.map((item, index) => (
<SearchResultListItem <SearchResultListItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index } key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
data={ item } data={ item }
...@@ -110,7 +124,7 @@ const SearchResultsPageContent = () => { ...@@ -110,7 +124,7 @@ const SearchResultsPageContent = () => {
searchTerm={ debouncedSearchTerm } searchTerm={ debouncedSearchTerm }
/> />
)) } )) }
{ data && data.items.map((item, index) => ( { displayedItems.map((item, index) => (
<SearchResultTableItem <SearchResultTableItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index } key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
data={ item } data={ item }
...@@ -130,7 +144,7 @@ const SearchResultsPageContent = () => { ...@@ -130,7 +144,7 @@ const SearchResultsPageContent = () => {
return null; return null;
} }
const resultsCount = pagination.page === 1 && !data?.next_page_params ? (data?.items.length || 0) + marketplaceApps.displayedApps.length : '50+'; const resultsCount = pagination.page === 1 && !data?.next_page_params ? (displayedItems.length || 0) + marketplaceApps.displayedApps.length : '50+';
const text = isPlaceholderData && pagination.page === 1 ? ( const text = isPlaceholderData && pagination.page === 1 ? (
<Skeleton h={ 6 } w="280px" borderRadius="full" mb={ pagination.isVisible ? 0 : 6 }/> <Skeleton h={ 6 } w="280px" borderRadius="full" mb={ pagination.isVisible ? 0 : 6 }/>
...@@ -141,7 +155,7 @@ const SearchResultsPageContent = () => { ...@@ -141,7 +155,7 @@ const SearchResultsPageContent = () => {
<chakra.span fontWeight={ 700 }> <chakra.span fontWeight={ 700 }>
{ resultsCount } { resultsCount }
</chakra.span> </chakra.span>
<span> matching result{ (((data?.items.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for </span> <span> matching result{ (((displayedItems.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for </span>
<chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span> <chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span>
</Box> </Box>
) )
......
...@@ -7,6 +7,7 @@ import type { RoutedTab } from 'ui/shared/Tabs/types'; ...@@ -7,6 +7,7 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata'; import * as metadata from 'lib/metadata';
import * as regexp from 'lib/regexp'; import * as regexp from 'lib/regexp';
...@@ -129,9 +130,7 @@ const TokenInstanceContent = () => { ...@@ -129,9 +130,7 @@ const TokenInstanceContent = () => {
) }, ) },
].filter(Boolean); ].filter(Boolean);
if (tokenInstanceQuery.isError) { throwOnResourceLoadError(tokenInstanceQuery);
throw Error('Token instance fetch failed', { cause: tokenInstanceQuery.error });
}
const tokenTag = <Tag isLoading={ tokenInstanceQuery.isPlaceholderData }>{ tokenQuery.data?.type }</Tag>; const tokenTag = <Tag isLoading={ tokenInstanceQuery.isPlaceholderData }>{ tokenQuery.data?.type }</Tag>;
......
...@@ -4,10 +4,9 @@ import React from 'react'; ...@@ -4,10 +4,9 @@ import React from 'react';
import type { RoutedTab } from 'ui/shared/Tabs/types'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
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';
...@@ -15,6 +14,7 @@ import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; ...@@ -15,6 +14,7 @@ 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 TxDetails from 'ui/tx/TxDetails'; import TxDetails from 'ui/tx/TxDetails';
import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded';
import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped'; import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped';
import TxInternals from 'ui/tx/TxInternals'; import TxInternals from 'ui/tx/TxInternals';
import TxLogs from 'ui/tx/TxLogs'; import TxLogs from 'ui/tx/TxLogs';
...@@ -22,36 +22,43 @@ import TxRawTrace from 'ui/tx/TxRawTrace'; ...@@ -22,36 +22,43 @@ import TxRawTrace from 'ui/tx/TxRawTrace';
import TxState from 'ui/tx/TxState'; import TxState from 'ui/tx/TxState';
import TxSubHeading from 'ui/tx/TxSubHeading'; import TxSubHeading from 'ui/tx/TxSubHeading';
import TxTokenTransfer from 'ui/tx/TxTokenTransfer'; import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import TxUserOps from 'ui/tx/TxUserOps';
import useTxQuery from 'ui/tx/useTxQuery';
const TransactionPageContent = () => { const TransactionPageContent = () => {
const router = useRouter(); const router = useRouter();
const appProps = useAppContext(); const appProps = useAppContext();
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
const txQuery = useTxQuery();
const { data, isPlaceholderData, isError, error, errorUpdateCount } = txQuery;
const { data, isPlaceholderData } = useApiQuery('tx', { const showDegradedView = (isError || isPlaceholderData) && errorUpdateCount > 0;
pathParams: { hash },
queryOptions: { const tabs: Array<RoutedTab> = (() => {
enabled: Boolean(hash), const detailsComponent = showDegradedView ?
placeholderData: TX, <TxDetailsDegraded hash={ hash } txQuery={ txQuery }/> :
}, <TxDetails txQuery={ txQuery }/>;
});
return [
const tabs: Array<RoutedTab> = [ {
{ id: 'index',
id: 'index', title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details',
title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details', component: detailsComponent,
component: <TxDetails/>, },
}, config.features.suave.isEnabled && data?.wrapped ?
config.features.suave.isEnabled && data?.wrapped ? { id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } :
{ id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } : undefined,
undefined, { id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer txQuery={ txQuery }/> },
{ id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer/> }, config.features.userOps.isEnabled ?
{ id: 'internal', title: 'Internal txns', component: <TxInternals/> }, { id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } :
{ id: 'logs', title: 'Logs', component: <TxLogs/> }, undefined,
{ id: 'state', title: 'State', component: <TxState/> }, { id: 'internal', title: 'Internal txns', component: <TxInternals txQuery={ txQuery }/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace/> }, { id: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery }/> },
].filter(Boolean); { id: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> },
].filter(Boolean);
})();
const tabIndex = useTabIndexFromQuery(tabs); const tabIndex = useTabIndexFromQuery(tabs);
...@@ -77,6 +84,23 @@ const TransactionPageContent = () => { ...@@ -77,6 +84,23 @@ const TransactionPageContent = () => {
const titleSecondRow = <TxSubHeading hash={ hash } hasTag={ Boolean(data?.tx_tag) }/>; const titleSecondRow = <TxSubHeading hash={ hash } hasTag={ Boolean(data?.tx_tag) }/>;
const content = (() => {
if (isPlaceholderData && !showDegradedView) {
return (
<>
<TabsSkeleton tabs={ tabs } mt={ 6 }/>
{ tabs[tabIndex]?.component }
</>
);
}
return <RoutedTabs tabs={ tabs }/>;
})();
if (error?.status === 422) {
throwOnResourceLoadError({ resource: 'tx', error, isError: true });
}
return ( return (
<> <>
<TextAd mb={ 6 }/> <TextAd mb={ 6 }/>
...@@ -86,12 +110,7 @@ const TransactionPageContent = () => { ...@@ -86,12 +110,7 @@ const TransactionPageContent = () => {
contentAfter={ tags } contentAfter={ tags }
secondRow={ titleSecondRow } secondRow={ titleSecondRow }
/> />
{ isPlaceholderData ? ( { content }
<>
<TabsSkeleton tabs={ tabs } mt={ 6 }/>
{ tabs[tabIndex]?.component }
</>
) : <RoutedTabs tabs={ tabs }/> }
</> </>
); );
}; };
......
import { test as base, expect, devices } from '@playwright/experimental-ct-react';
import React from 'react';
import { userOpData } from 'mocks/userOps/userOp';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import UserOp from './UserOp';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
const USER_OP_API_URL = buildApiUrl('user_op', { hash: userOpData.hash });
test('base view', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(USER_OP_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpData),
}));
const component = await mount(
<TestApp>
<UserOp/>
</TestApp>,
{ hooksConfig: {
router: {
query: { hash: userOpData.hash },
isReady: true,
},
} },
);
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(USER_OP_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpData),
}));
const component = await mount(
<TestApp>
<UserOp/>
</TestApp>,
{ hooksConfig: {
router: {
query: { hash: userOpData.hash },
isReady: true,
},
} },
);
await expect(component).toHaveScreenshot();
});
});
import { inRange } from 'lodash';
import { useRouter } from 'next/router';
import React from 'react';
import type { Log } from 'types/api/log';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { USER_OP } from 'stubs/userOps';
import TextAd from 'ui/shared/ad/TextAd';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxLogs from 'ui/tx/TxLogs';
import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import useTxQuery from 'ui/tx/useTxQuery';
import UserOpDetails from 'ui/userOp/UserOpDetails';
import UserOpRaw from 'ui/userOp/UserOpRaw';
const UserOp = () => {
const router = useRouter();
const appProps = useAppContext();
const hash = getQueryParamString(router.query.hash);
const userOpQuery = useApiQuery('user_op', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: USER_OP,
},
});
const txQuery = useTxQuery({ hash: userOpQuery.data?.transaction_hash, isEnabled: !userOpQuery.isPlaceholderData });
const filterTokenTransfersByLogIndex = React.useCallback((tt: TokenTransfer) => {
if (!userOpQuery.data) {
return true;
} else {
if (inRange(Number(tt.log_index), userOpQuery.data?.user_logs_start_index, userOpQuery.data?.user_logs_start_index + userOpQuery.data?.user_logs_count)) {
return true;
}
return false;
}
}, [ userOpQuery.data ]);
const filterLogsByLogIndex = React.useCallback((log: Log) => {
if (!userOpQuery.data) {
return true;
} else {
if (inRange(log.index, userOpQuery.data?.user_logs_start_index, userOpQuery.data?.user_logs_start_index + userOpQuery.data?.user_logs_count)) {
return true;
}
return false;
}
}, [ userOpQuery.data ]);
const tabs: Array<RoutedTab> = React.useMemo(() => ([
{ id: 'index', title: 'Details', component: <UserOpDetails query={ userOpQuery }/> },
{
id: 'token_transfers',
title: 'Token transfers',
component: <TxTokenTransfer txQuery={ txQuery } tokenTransferFilter={ filterTokenTransfersByLogIndex }/>,
},
{ id: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery } logsFilter={ filterLogsByLogIndex }/> },
{ id: 'raw', title: 'Raw', component: <UserOpRaw rawData={ userOpQuery.data?.raw } isLoading={ userOpQuery.isPlaceholderData }/> },
]), [ userOpQuery, txQuery, filterTokenTransfersByLogIndex, filterLogsByLogIndex ]);
const tabIndex = useTabIndexFromQuery(tabs);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.includes('/ops');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to user operations list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
throwOnAbsentParamError(hash);
throwOnResourceLoadError(userOpQuery);
const titleSecondRow = <UserOpEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } fontFamily="heading"/>;
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title="User operation details"
backLink={ backLink }
secondRow={ titleSecondRow }
/>
{ userOpQuery.isPlaceholderData ? (
<>
<TabsSkeleton tabs={ tabs } mt={ 6 }/>
{ tabs[tabIndex]?.component }
</>
) :
<RoutedTabs tabs={ tabs }/> }
</>
);
};
export default UserOp;
import { Box } from '@chakra-ui/react';
import { test as base, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { userOpsData } from 'mocks/userOps/userOps';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import UserOps from './UserOps';
const test = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
const USER_OPS_API_URL = buildApiUrl('user_ops');
test('base view +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
await page.route(USER_OPS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(userOpsData),
}));
const component = await mount(
<TestApp>
<Box pt={{ base: '106px', lg: 0 }}>
<UserOps/>
</Box>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import React from 'react';
import { USER_OPS_ITEM } from 'stubs/userOps';
import { generateListStub } from 'stubs/utils';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import UserOpsContent from 'ui/userOps/UserOpsContent';
const UserOps = () => {
const query = useQueryWithPages({
resourceName: 'user_ops',
options: {
placeholderData: generateListStub<'user_ops'>(USER_OPS_ITEM, 50, { next_page_params: {
page_token: '10355938,0x5956a847d8089e254e02e5111cad6992b99ceb9e5c2dc4343fd53002834c4dc6',
page_size: 50,
} }),
},
});
return (
<>
<PageTitle title="User operations" withTextAd/>
<UserOpsContent query={ query }/>
</>
);
};
export default UserOps;
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ 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 getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import { currencyUnits } from 'lib/units';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import { WITHDRAWAL } from 'stubs/withdrawals'; import { WITHDRAWAL } from 'stubs/withdrawals';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
...@@ -64,7 +65,7 @@ const Withdrawals = () => { ...@@ -64,7 +65,7 @@ const Withdrawals = () => {
{ countersQuery.data && ( { countersQuery.data && (
<Text lineHeight={{ base: '24px', lg: '32px' }}> <Text lineHeight={{ base: '24px', lg: '32px' }}>
{ BigNumber(countersQuery.data.withdrawal_count).toFormat() } withdrawals processed { BigNumber(countersQuery.data.withdrawal_count).toFormat() } withdrawals processed
and { getCurrencyValue({ value: countersQuery.data.withdrawal_sum }).valueStr } { feature.currency.symbol } withdrawn and { getCurrencyValue({ value: countersQuery.data.withdrawal_sum }).valueStr } { currencyUnits.ether } withdrawn
</Text> </Text>
) } ) }
</Skeleton> </Skeleton>
......
...@@ -5,6 +5,8 @@ import type { RoutedTab } from 'ui/shared/Tabs/types'; ...@@ -5,6 +5,8 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { TX_ZKEVM_L2 } from 'stubs/tx'; import { TX_ZKEVM_L2 } from 'stubs/tx';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
...@@ -41,13 +43,8 @@ const ZkEvmL2TxnBatch = () => { ...@@ -41,13 +43,8 @@ const ZkEvmL2TxnBatch = () => {
}, },
}); });
if (!number) { throwOnAbsentParamError(number);
throw new Error('Tx batch not found', { cause: { status: 404 } }); throwOnResourceLoadError(batchQuery);
}
if (batchQuery.isError) {
throw new Error(undefined, { cause: batchQuery.error });
}
const tabs: Array<RoutedTab> = React.useMemo(() => ([ const tabs: Array<RoutedTab> = React.useMemo(() => ([
{ id: 'index', title: 'Details', component: <ZkEvmL2TxnBatchDetails query={ batchQuery }/> }, { id: 'index', title: 'Details', component: <ZkEvmL2TxnBatchDetails query={ batchQuery }/> },
......
...@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
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';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal'; import LinkExternal from 'ui/shared/LinkExternal';
...@@ -56,7 +57,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -56,7 +57,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
wordBreak="break-all" wordBreak="break-all"
isLoading={ isLoading } isLoading={ isLoading }
onClick={ handleLinkClick } onClick={ handleLinkClick }
flexGrow={ 1 }
overflow="hidden" overflow="hidden"
> >
<Skeleton <Skeleton
...@@ -200,6 +200,26 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -200,6 +200,26 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
</TxEntity.Container> </TxEntity.Container>
); );
} }
case 'user_operation': {
return (
<UserOpEntity.Container>
<UserOpEntity.Icon/>
<UserOpEntity.Link
isLoading={ isLoading }
hash={ data.user_operation_hash }
onClick={ handleLinkClick }
>
<UserOpEntity.Content
asProp="mark"
hash={ data.user_operation_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</UserOpEntity.Link>
</UserOpEntity.Container>
);
}
} }
})(); })();
...@@ -240,6 +260,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -240,6 +260,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text> <Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
); );
} }
case 'user_operation': {
return (
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
);
}
case 'label': { case 'label': {
return ( return (
<Flex alignItems="center"> <Flex alignItems="center">
...@@ -295,12 +321,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -295,12 +321,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
return ( return (
<ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }> <ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }>
<Flex justifyContent="space-between" w="100%" overflow="hidden" lineHeight={ 6 }> <Grid templateColumns="1fr auto" w="100%" overflow="hidden" lineHeight={ 6 }>
{ firstRow } { firstRow }
<Skeleton isLoaded={ !isLoading } color="text_secondary" ml={ 8 } textTransform="capitalize"> <Skeleton isLoaded={ !isLoading } color="text_secondary" ml={ 8 } textTransform="capitalize">
<span>{ category ? searchItemTitles[category].itemTitleShort : '' }</span> <span>{ category ? searchItemTitles[category].itemTitleShort : '' }</span>
</Skeleton> </Skeleton>
</Flex> </Grid>
{ Boolean(secondRow) && ( { Boolean(secondRow) && (
<Box w="100%" overflow="hidden" whiteSpace={ data.type !== 'app' ? 'nowrap' : undefined }> <Box w="100%" overflow="hidden" whiteSpace={ data.type !== 'app' ? 'nowrap' : undefined }>
{ secondRow } { secondRow }
......
...@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
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';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal'; import LinkExternal from 'ui/shared/LinkExternal';
...@@ -284,6 +285,33 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -284,6 +285,33 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
</> </>
); );
} }
case 'user_operation': {
return (
<>
<Td colSpan={ 2 } fontSize="sm">
<UserOpEntity.Container>
<UserOpEntity.Icon/>
<UserOpEntity.Link
isLoading={ isLoading }
hash={ data.user_operation_hash }
onClick={ handleLinkClick }
>
<UserOpEntity.Content
asProp="mark"
hash={ data.user_operation_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</UserOpEntity.Link>
</UserOpEntity.Container>
</Td>
<Td fontSize="sm" verticalAlign="middle" isNumeric>
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
</Td>
</>
);
}
} }
})(); })();
......
import { Button, Menu, MenuButton, MenuList, Flex, Skeleton, chakra } from '@chakra-ui/react'; import { IconButton, Menu, MenuButton, MenuList, Skeleton, chakra } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
...@@ -37,16 +37,14 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => { ...@@ -37,16 +37,14 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
<Menu> <Menu>
<Skeleton isLoaded={ !isLoading } borderRadius="base" className={ className }> <Skeleton isLoaded={ !isLoading } borderRadius="base" className={ className }>
<MenuButton <MenuButton
as={ Button } as={ IconButton }
size="sm" size="sm"
variant="outline" variant="outline"
colorScheme="gray"
px="7px"
onClick={ handleButtonClick } onClick={ handleButtonClick }
> icon={ <IconSvg name="dots" boxSize="18px"/> }
<Flex alignItems="center"> />
<span>More</span>
<IconSvg name="arrows/east-mini" transform="rotate(-90deg)" boxSize={ 5 } ml={ 1 }/>
</Flex>
</MenuButton>
</Skeleton> </Skeleton>
<MenuList minWidth="180px" zIndex="popover"> <MenuList minWidth="180px" zIndex="popover">
{ isTokenPage && config.features.addressVerification.isEnabled && { isTokenPage && config.features.addressVerification.isEnabled &&
......
...@@ -37,7 +37,7 @@ test('status code 500', async({ mount }) => { ...@@ -37,7 +37,7 @@ test('status code 500', async({ mount }) => {
}); });
test('invalid tx hash', async({ mount }) => { test('invalid tx hash', async({ mount }) => {
const error = { message: 'Invalid tx hash', cause: { status: 404 } } as Error; const error = { message: 'Invalid tx hash', cause: { status: 422, resource: 'tx' } } as Error;
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<AppError error={ error }/> <AppError error={ error }/>
......
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import getErrorCause from 'lib/errors/getErrorCause';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode'; import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode'; import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload'; import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
...@@ -36,6 +37,7 @@ const ERROR_TEXTS: Record<string, { title: string; text: string }> = { ...@@ -36,6 +37,7 @@ const ERROR_TEXTS: Record<string, { title: string; text: string }> = {
const AppError = ({ error, className }: Props) => { const AppError = ({ error, className }: Props) => {
const content = (() => { const content = (() => {
const resourceErrorPayload = getResourceErrorPayload(error); const resourceErrorPayload = getResourceErrorPayload(error);
const cause = getErrorCause(error);
const messageInPayload = const messageInPayload =
resourceErrorPayload && resourceErrorPayload &&
typeof resourceErrorPayload === 'object' && typeof resourceErrorPayload === 'object' &&
...@@ -43,8 +45,9 @@ const AppError = ({ error, className }: Props) => { ...@@ -43,8 +45,9 @@ const AppError = ({ error, className }: Props) => {
typeof resourceErrorPayload.message === 'string' ? typeof resourceErrorPayload.message === 'string' ?
resourceErrorPayload.message : resourceErrorPayload.message :
undefined; undefined;
const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error);
const isInvalidTxHash = error?.message?.includes('Invalid tx hash'); const isInvalidTxHash = cause && 'resource' in cause && cause.resource === 'tx' && statusCode === 422;
const isBlockConsensus = messageInPayload?.includes('Block lost consensus'); const isBlockConsensus = messageInPayload?.includes('Block lost consensus');
if (isInvalidTxHash) { if (isInvalidTxHash) {
...@@ -62,8 +65,6 @@ const AppError = ({ error, className }: Props) => { ...@@ -62,8 +65,6 @@ const AppError = ({ error, className }: Props) => {
return <AppErrorBlockConsensus hash={ hash }/>; return <AppErrorBlockConsensus hash={ hash }/>;
} }
const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error);
switch (statusCode) { switch (statusCode) {
case 429: { case 429: {
return <AppErrorTooManyRequests/>; return <AppErrorTooManyRequests/>;
......
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg';
import TextSeparator from 'ui/shared/TextSeparator';
type Props = {
// should be string, will be fixed on the back-end
timestamp: string | number;
isLoading?: boolean;
}
const DetailsTimestamp = ({ timestamp, isLoading }: Props) => {
return (
<>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isLoading }/>
<Skeleton isLoaded={ !isLoading } ml={ 2 }>
{ dayjs(timestamp).fromNow() }
</Skeleton>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isLoading } whiteSpace="normal">
{ dayjs(timestamp).format('llll') }
</Skeleton>
</>
);
};
export default DetailsTimestamp;
import type { NextRouter } from 'next/router';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
interface Props { interface Props {
...@@ -6,21 +8,33 @@ interface Props { ...@@ -6,21 +8,33 @@ interface Props {
onError?: (error: Error) => void; onError?: (error: Error) => void;
} }
interface PropsWithRouter extends Props {
router: NextRouter;
}
interface State { interface State {
hasError: boolean; hasError: boolean;
error?: Error; error?: Error;
errorPathname?: string;
} }
class ErrorBoundary extends React.PureComponent<Props, State> { class ErrorBoundary extends React.PureComponent<PropsWithRouter, State> {
state: State = { state: State = {
hasError: false, hasError: false,
}; };
static getDerivedStateFromError(error: Error) { static getDerivedStateFromProps(props: PropsWithRouter, state: State) {
return { hasError: true, error }; if (state.hasError && state.errorPathname) {
if (props.router.pathname !== state.errorPathname) {
return { hasError: false, error: undefined, errorPathname: undefined };
}
}
return null;
} }
componentDidCatch(error: Error) { componentDidCatch(error: Error) {
this.setState({ hasError: true, error, errorPathname: this.props.router.pathname });
this.props.onError?.(error); this.props.onError?.(error);
} }
...@@ -33,4 +47,9 @@ class ErrorBoundary extends React.PureComponent<Props, State> { ...@@ -33,4 +47,9 @@ class ErrorBoundary extends React.PureComponent<Props, State> {
} }
} }
export default ErrorBoundary; const WrappedErrorBoundary = (props: Props) => {
const router = useRouter();
return <ErrorBoundary { ...props } router={ router }/>;
};
export default React.memo(WrappedErrorBoundary);
...@@ -4,6 +4,7 @@ import React from 'react'; ...@@ -4,6 +4,7 @@ import React from 'react';
import type { GasPriceInfo } from 'types/api/stats'; import type { GasPriceInfo } from 'types/api/stats';
import { asymp, space } from 'lib/html-entities'; import { asymp, space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
interface Props { interface Props {
name: string; name: string;
...@@ -18,7 +19,7 @@ const GasInfoRow = ({ name, info }: Props) => { ...@@ -18,7 +19,7 @@ const GasInfoRow = ({ name, info }: Props) => {
return ( return (
<> <>
<span>{ info.fiat_price ? `$${ info.fiat_price }` : `${ info.price } Gwei` }</span> <span>{ info.fiat_price ? `$${ info.fiat_price }` : `${ info.price } ${ currencyUnits.gwei }` }</span>
{ info.time && ( { info.time && (
<chakra.span color="text_secondary"> <chakra.span color="text_secondary">
{ space }per tx { asymp } { (info.time / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s { space }per tx { asymp } { (info.time / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s
......
import { Flex, Button, chakra, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure } from '@chakra-ui/react'; import { Flex, Button, chakra, Popover, PopoverTrigger, PopoverBody, PopoverContent, useDisclosure, Show, Hide } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { NetworkExplorer as TNetworkExplorer } from 'types/networks'; import type { NetworkExplorer as TNetworkExplorer } from 'types/networks';
...@@ -46,7 +46,12 @@ const NetworkExplorers = ({ className, type, pathParam }: Props) => { ...@@ -46,7 +46,12 @@ const NetworkExplorers = ({ className, type, pathParam }: Props) => {
flexShrink={ 0 } flexShrink={ 0 }
> >
<IconSvg name="explorer" boxSize={ 5 }/> <IconSvg name="explorer" boxSize={ 5 }/>
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 }/> <Show above="xl">
<chakra.span ml={ 1 }>{ explorersLinks.length } Explorer{ explorersLinks.length > 1 ? 's' : '' }</chakra.span>
</Show>
<Hide above="xl">
<chakra.span ml={ 1 }>{ explorersLinks.length }</chakra.span>
</Hide>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w="240px"> <PopoverContent w="240px">
......
...@@ -2,10 +2,10 @@ import { useColorMode } from '@chakra-ui/react'; ...@@ -2,10 +2,10 @@ import { useColorMode } from '@chakra-ui/react';
import { jsonRpcProvider } from '@wagmi/core/providers/jsonRpc'; import { jsonRpcProvider } from '@wagmi/core/providers/jsonRpc';
import { createWeb3Modal, useWeb3ModalTheme, defaultWagmiConfig } from '@web3modal/wagmi/react'; import { createWeb3Modal, useWeb3ModalTheme, defaultWagmiConfig } from '@web3modal/wagmi/react';
import React from 'react'; import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, WagmiConfig } from 'wagmi'; import { configureChains, WagmiConfig } from 'wagmi';
import config from 'configs/app'; import config from 'configs/app';
import currentChain from 'lib/web3/currentChain';
import colors from 'theme/foundations/colors'; import colors from 'theme/foundations/colors';
import { BODY_TYPEFACE } from 'theme/foundations/typography'; import { BODY_TYPEFACE } from 'theme/foundations/typography';
import zIndices from 'theme/foundations/zIndices'; import zIndices from 'theme/foundations/zIndices';
...@@ -18,31 +18,6 @@ const getConfig = () => { ...@@ -18,31 +18,6 @@ const getConfig = () => {
throw new Error(); throw new Error();
} }
const currentChain: Chain = {
id: Number(config.chain.id),
name: config.chain.name || '',
network: config.chain.name || '',
nativeCurrency: {
decimals: config.chain.currency.decimals,
name: config.chain.currency.name || '',
symbol: config.chain.currency.symbol || '',
},
rpcUrls: {
'public': {
http: [ config.chain.rpcUrl || '' ],
},
'default': {
http: [ config.chain.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: config.app.baseUrl,
},
},
};
const { chains } = configureChains( const { chains } = configureChains(
[ currentChain ], [ currentChain ],
[ [
......
...@@ -96,7 +96,7 @@ const AddressAddToWallet = ({ className, token, isLoading, variant = 'icon', ico ...@@ -96,7 +96,7 @@ const AddressAddToWallet = ({ className, token, isLoading, variant = 'icon', ico
aria-label="Add token to wallet" aria-label="Add token to wallet"
variant="outline" variant="outline"
size="sm" size="sm"
px="6px" px={ 1 }
onClick={ handleClick } onClick={ handleClick }
icon={ <IconSvg name={ WALLETS_INFO[wallet].icon } boxSize={ 6 }/> } icon={ <IconSvg name={ WALLETS_INFO[wallet].icon } boxSize={ 6 }/> }
flexShrink={ 0 } flexShrink={ 0 }
......
...@@ -57,7 +57,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading ...@@ -57,7 +57,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading
w="min-content" w="min-content"
/> />
</Flex> </Flex>
{ to ? ( { to && (
<Entity <Entity
address={ to } address={ to }
isLoading={ isLoading } isLoading={ isLoading }
...@@ -70,7 +70,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading ...@@ -70,7 +70,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading
w="min-content" w="min-content"
ml="28px" ml="28px"
/> />
) : <span>-</span> } ) }
</Flex> </Flex>
); );
} }
...@@ -95,7 +95,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading ...@@ -95,7 +95,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading
isLoading={ isLoading } isLoading={ isLoading }
type={ getTxCourseType(from.hash, to?.hash, current) } type={ getTxCourseType(from.hash, to?.hash, current) }
/> />
{ to ? ( { to && (
<Entity <Entity
address={ to } address={ to }
isLoading={ isLoading } isLoading={ isLoading }
...@@ -107,7 +107,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading ...@@ -107,7 +107,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading
maxW={ truncation === 'constant' ? undefined : `calc(50% - ${ iconSizeWithMargins / 2 }px)` } maxW={ truncation === 'constant' ? undefined : `calc(50% - ${ iconSizeWithMargins / 2 }px)` }
ml={ 3 } ml={ 3 }
/> />
) : <span>-</span> } ) }
</Flex> </Flex>
); );
}; };
......
import type { AddressParam } from 'types/api/addressParams';
export type TxCourseType = 'in' | 'out' | 'self' | 'unspecified'; export type TxCourseType = 'in' | 'out' | 'self' | 'unspecified';
export function getTxCourseType(from: string, to: string | undefined, current?: string): TxCourseType { export function getTxCourseType(from: string, to: string | undefined, current?: string): TxCourseType {
...@@ -19,3 +21,14 @@ export function getTxCourseType(from: string, to: string | undefined, current?: ...@@ -19,3 +21,14 @@ export function getTxCourseType(from: string, to: string | undefined, current?:
return 'unspecified'; return 'unspecified';
} }
export const unknownAddress: Omit<AddressParam, 'hash'> = {
is_contract: false,
is_verified: false,
implementation_name: '',
name: '',
private_tags: [],
public_tags: [],
watchlist_names: [],
ens_domain_name: null,
};
import { Alert, Skeleton, Spinner, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
isLoading?: boolean;
className?: string;
}
const ServiceDegradationWarning = ({ isLoading, className }: Props) => {
return (
<Skeleton className={ className } isLoaded={ !isLoading }>
<Alert status="warning" colorScheme="gray" alignItems={{ base: 'flex-start', lg: 'center' }}>
<Spinner size="sm" mr={ 2 } my={{ base: '3px', lg: 0 }} flexShrink={ 0 }/>
Data sync in progress... page will refresh automatically once transaction data is available
</Alert>
</Skeleton>
);
};
export default React.memo(chakra(ServiceDegradationWarning));
import { Alert, Skeleton, chakra } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
interface Props {
isLoading?: boolean;
className?: string;
}
const TestnetWarning = ({ isLoading, className }: Props) => {
if (!config.chain.isTestnet) {
return null;
}
return (
<Skeleton className={ className } isLoaded={ !isLoading }>
<Alert status="warning">This is a testnet transaction only</Alert>
</Skeleton>
);
};
export default React.memo(chakra(TestnetWarning));
...@@ -207,7 +207,7 @@ const ChartWidget = ({ items, title, description, isLoading, className, isError, ...@@ -207,7 +207,7 @@ const ChartWidget = ({ items, title, description, isLoading, className, isError,
<MenuButton <MenuButton
w="36px" w="36px"
h="32px" h="32px"
icon={ <IconSvg name="vertical_dots" w={ 4 } h={ 4 }/> } icon={ <IconSvg name="dots" boxSize={ 4 } transform="rotate(-90deg)"/> }
colorScheme="gray" colorScheme="gray"
variant="ghost" variant="ghost"
as={ IconButton } as={ IconButton }
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import UserOpEntity from './UserOpEntity';
const hash = '0x376db52955d5bce114d0ccea2dcf22289b4eae1b86bcae5a59bb5fdbfef48899';
const iconSizes = [ 'md', 'lg' ];
test.use({ viewport: { width: 180, height: 30 } });
test.describe('icon size', () => {
iconSizes.forEach((size) => {
test(size, async({ mount }) => {
const component = await mount(
<TestApp>
<UserOpEntity
hash={ hash }
iconSize={ size }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
});
});
test('loading', async({ mount }) => {
const component = await mount(
<TestApp>
<UserOpEntity
hash={ hash }
isLoading
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('with copy +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<UserOpEntity
hash={ hash }
noCopy={ false }
/>
</TestApp>,
);
await component.getByText(hash.slice(0, 4)).hover();
await expect(component).toHaveScreenshot();
});
test('customization', async({ mount }) => {
const component = await mount(
<TestApp>
<UserOpEntity
hash={ hash }
truncation="constant"
p={ 3 }
borderWidth="1px"
borderColor="blue.700"
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
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: '/op/[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 ?? 'user_op_slim' }
/>
);
};
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'hash'>;
const Content = chakra((props: ContentProps) => {
return (
<EntityBase.Content
{ ...props }
text={ props.hash }
/>
);
});
type CopyProps = Omit<EntityBase.CopyBaseProps, 'text'> & Pick<EntityProps, 'hash'>;
const Copy = (props: CopyProps) => {
return (
<EntityBase.Copy
{ ...props }
text={ props.hash }
// by default we don't show copy icon, maybe this should be revised
noCopy={ props.noCopy ?? true }
/>
);
};
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string;
}
const UserOpEntity = (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(UserOpEntity));
export {
Container,
Link,
Icon,
Content,
Copy,
};
import type { PaginationParams } from './types';
export const emptyPagination: PaginationParams = {
page: 1,
onNextPageClick: () => {},
onPrevPageClick: () => {},
resetPage: () => {},
hasPages: false,
hasNextPage: false,
canGoBackwards: false,
isLoading: false,
isVisible: false,
};
import type { SearchResultItem } from 'types/api/search'; import type { SearchResultItem } from 'types/api/search';
import type { MarketplaceAppOverview } from 'types/client/marketplace'; import type { MarketplaceAppOverview } from 'types/client/marketplace';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block'; import config from 'configs/app';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation';
export type Category = ApiCategory | 'app'; export type Category = ApiCategory | 'app';
export type ItemsCategoriesMap = export type ItemsCategoriesMap =
...@@ -23,6 +25,10 @@ export const searchCategories: Array<{id: Category; title: string }> = [ ...@@ -23,6 +25,10 @@ export const searchCategories: Array<{id: Category; title: string }> = [
{ id: 'block', title: 'Blocks' }, { id: 'block', title: 'Blocks' },
]; ];
if (config.features.userOps.isEnabled) {
searchCategories.push({ id: 'user_operation', title: 'User operations' });
}
export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleShort: string }> = { export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleShort: string }> = {
app: { itemTitle: 'App', itemTitleShort: 'App' }, app: { itemTitle: 'App', itemTitleShort: 'App' },
token: { itemTitle: 'Token', itemTitleShort: 'Token' }, token: { itemTitle: 'Token', itemTitleShort: 'Token' },
...@@ -31,6 +37,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh ...@@ -31,6 +37,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
public_tag: { itemTitle: 'Public tag', itemTitleShort: 'Tag' }, public_tag: { itemTitle: 'Public tag', itemTitleShort: 'Tag' },
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' },
}; };
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined { export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
...@@ -57,5 +64,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C ...@@ -57,5 +64,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'app': { case 'app': {
return 'app'; return 'app';
} }
case 'user_operation': {
return 'user_operation';
}
} }
} }
...@@ -12,6 +12,10 @@ export interface Props { ...@@ -12,6 +12,10 @@ export interface Props {
} }
const TxStatus = ({ status, errorText, isLoading }: Props) => { const TxStatus = ({ status, errorText, isLoading }: Props) => {
if (status === undefined) {
return null;
}
let text; let text;
let type: StatusTagType; let type: StatusTagType;
......
import { Tag } from '@chakra-ui/react';
import React from 'react';
import { UserOpSponsorType } from 'types/api/userOps';
type Props = {
sponsorType: UserOpSponsorType;
}
const UserOpSponsorType = ({ sponsorType }: Props) => {
let text: string = sponsorType;
switch (sponsorType) {
case 'paymaster_hybrid':
text = 'Paymaster hybrid';
break;
case 'paymaster_sponsor':
text = 'Paymaster sponsor';
break;
case 'wallet_balance':
text = 'Wallet balance';
break;
case 'wallet_deposit':
text = 'Wallet deposit';
}
return <Tag>{ text }</Tag>;
};
export default UserOpSponsorType;
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import StatusTag from 'ui/shared/statusTag/StatusTag';
type Props = {
status?: boolean;
isLoading?: boolean;
}
const UserOpStatus = ({ status, isLoading }: Props) => {
if (status === undefined) {
return null;
}
return (
<Skeleton isLoaded={ !isLoading } display="inline-block">
<StatusTag type={ status === true ? 'ok' : 'error' } text={ status === true ? 'Success' : 'Failed' }/>
</Skeleton>
);
};
export default UserOpStatus;
import React from 'react';
import type { AddressParamBasic } from 'types/api/addressParams';
import AddressEntity from '../entities/address/AddressEntity';
import type { EntityProps } from '../entities/address/AddressEntity';
type Props = Omit<EntityProps, 'address'> & {
address: string | AddressParamBasic;
}
const UserOpsAddress = ({ address, ...props }: Props) => {
let addressParam;
if (typeof address === 'string') {
addressParam = { hash: address };
} else {
addressParam = address;
}
return <AddressEntity address={ addressParam } { ...props }/>;
};
export default UserOpsAddress;
...@@ -7,15 +7,15 @@ import { NETWORK_GROUPS } from 'types/networks'; ...@@ -7,15 +7,15 @@ import { NETWORK_GROUPS } from 'types/networks';
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
export default function useNetworkMenu() { export default function useNetworkMenu() {
const { isOpen, onClose, onOpen, onToggle } = useDisclosure(); const { isOpen, onClose, onOpen, onToggle } = useDisclosure();
const apiFetch = useApiFetch(); const fetch = useFetch();
const { isPending, data } = useQuery<unknown, ResourceError<unknown>, Array<FeaturedNetwork>>({ const { isPending, data } = useQuery<unknown, ResourceError<unknown>, Array<FeaturedNetwork>>({
queryKey: [ 'featured-network' ], queryKey: [ 'featured-network' ],
queryFn: async() => apiFetch(config.UI.sidebar.featuredNetworks || '', undefined, { resource: 'featured-network' }), queryFn: async() => fetch(config.UI.sidebar.featuredNetworks || '', undefined, { resource: 'featured-network' }),
enabled: Boolean(config.UI.sidebar.featuredNetworks) && isOpen, enabled: Boolean(config.UI.sidebar.featuredNetworks) && isOpen,
staleTime: Infinity, staleTime: Infinity,
}); });
......
...@@ -9,6 +9,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; ...@@ -9,6 +9,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app'; import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
...@@ -204,6 +205,35 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -204,6 +205,35 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
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 } });
}); });
const testWithUserOps = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.tx1.tx_hash }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
searchMock.userOp1,
]),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search with view all link', async({ mount, page }) => { test('search with view all link', async({ mount, page }) => {
const API_URL = buildApiUrl('quick_search') + '?q=o'; const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({ await page.route(API_URL, (route) => route.fulfill({
......
...@@ -111,12 +111,12 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props ...@@ -111,12 +111,12 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>; return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
} }
if (!query.data || query.data.length === 0) { const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
if (resultCategories.length === 0) {
return <Text>No results found.</Text>; return <Text>No results found.</Text>;
} }
const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
return ( return (
<> <>
{ resultCategories.length > 1 && ( { resultCategories.length > 1 && (
......
...@@ -12,6 +12,7 @@ import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; ...@@ -12,6 +12,7 @@ import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel'; import SearchBarSuggestLabel from './SearchBarSuggestLabel';
import SearchBarSuggestToken from './SearchBarSuggestToken'; import SearchBarSuggestToken from './SearchBarSuggestToken';
import SearchBarSuggestTx from './SearchBarSuggestTx'; import SearchBarSuggestTx from './SearchBarSuggestTx';
import SearchBarSuggestUserOp from './SearchBarSuggestUserOp';
interface Props { interface Props {
data: SearchResultItem; data: SearchResultItem;
...@@ -38,6 +39,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -38,6 +39,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'block': { case 'block': {
return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } }); return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } });
} }
case 'user_operation': {
return route({ pathname: '/op/[hash]', query: { hash: data.user_operation_hash } });
}
} }
})(); })();
...@@ -60,6 +64,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -60,6 +64,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'transaction': { case 'transaction': {
return <SearchBarSuggestTx data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>; return <SearchBarSuggestTx data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
} }
case 'user_operation': {
return <SearchBarSuggestUserOp data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
} }
})(); })();
......
import { chakra, Text, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultUserOp } from 'types/api/search';
import dayjs from 'lib/date/dayjs';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultUserOp;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestTx = ({ data, isMobile }: Props) => {
const icon = <UserOpEntity.Icon/>;
const hash = (
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
<HashStringShortenDynamic hash={ data.user_operation_hash } isTooltipDisabled/>
</chakra.mark>
);
const date = dayjs(data.timestamp).format('llll');
if (isMobile) {
return (
<>
<Flex alignItems="center">
{ icon }
{ hash }
</Flex>
<Text variant="secondary">{ date }</Text>
</>
);
}
return (
<Flex columnGap={ 2 }>
<Flex alignItems="center" minW={ 0 }>
{ icon }
{ hash }
</Flex>
<Text variant="secondary" textAlign="end" flexShrink={ 0 } ml="auto">{ date }</Text>
</Flex>
);
};
export default React.memo(SearchBarSuggestTx);
...@@ -4,6 +4,7 @@ import React from 'react'; ...@@ -4,6 +4,7 @@ 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 dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS } from 'stubs/stats';
import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent'; import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent';
import TextSeparator from 'ui/shared/TextSeparator'; import TextSeparator from 'ui/shared/TextSeparator';
...@@ -94,7 +95,7 @@ const TopBarStats = () => { ...@@ -94,7 +95,7 @@ const TopBarStats = () => {
onMouseEnter={ onOpen } onMouseEnter={ onOpen }
onMouseLeave={ onClose } onMouseLeave={ onClose }
> >
{ data.gas_prices.average.fiat_price ? `$${ data.gas_prices.average.fiat_price }` : `${ data.gas_prices.average.price } Gwei` } { data.gas_prices.average.fiat_price ? `$${ data.gas_prices.average.fiat_price }` : `${ data.gas_prices.average.price } ${ currencyUnits.gwei }` }
</Link> </Link>
</Tooltip> </Tooltip>
</Skeleton> </Skeleton>
......
...@@ -17,7 +17,7 @@ type Props = { ...@@ -17,7 +17,7 @@ type Props = {
}; };
const WalletMenuDesktop = ({ isHomePage }: Props) => { const WalletMenuDesktop = ({ isHomePage }: Props) => {
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet(); const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet({ source: 'Header' });
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors(); const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false); const [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
......
...@@ -13,7 +13,7 @@ import WalletTooltip from './WalletTooltip'; ...@@ -13,7 +13,7 @@ import WalletTooltip from './WalletTooltip';
const WalletMenuMobile = () => { const WalletMenuMobile = () => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet(); const { isWalletConnected, address, connect, disconnect, isModalOpening, isModalOpen } = useWallet({ source: 'Header' });
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors(); const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
......
...@@ -4,12 +4,17 @@ import { useAccount, useDisconnect } from 'wagmi'; ...@@ -4,12 +4,17 @@ import { useAccount, useDisconnect } from 'wagmi';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
export default function useWallet() { interface Params {
source: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_CONNECT>['Source'];
}
export default function useWallet({ source }: Params) {
const { open } = useWeb3Modal(); const { open } = useWeb3Modal();
const { open: isOpen } = useWeb3ModalState(); const { open: isOpen } = useWeb3ModalState();
const { disconnect } = useDisconnect(); const { disconnect } = useDisconnect();
const [ isModalOpening, setIsModalOpening ] = React.useState(false); const [ isModalOpening, setIsModalOpening ] = React.useState(false);
const [ isClientLoaded, setIsClientLoaded ] = React.useState(false); const [ isClientLoaded, setIsClientLoaded ] = React.useState(false);
const isConnectionStarted = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
setIsClientLoaded(true); setIsClientLoaded(true);
...@@ -19,12 +24,15 @@ export default function useWallet() { ...@@ -19,12 +24,15 @@ export default function useWallet() {
setIsModalOpening(true); setIsModalOpening(true);
await open(); await open();
setIsModalOpening(false); setIsModalOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Header', Status: 'Started' }); mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Started' });
}, [ open ]); isConnectionStarted.current = true;
}, [ open, source ]);
const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => { const handleAccountConnected = React.useCallback(({ isReconnected }: { isReconnected: boolean }) => {
!isReconnected && mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Header', Status: 'Connected' }); !isReconnected && isConnectionStarted.current &&
}, []); mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Connected' });
isConnectionStarted.current = false;
}, [ source ]);
const handleDisconnect = React.useCallback(() => { const handleDisconnect = React.useCallback(() => {
disconnect(); disconnect();
......
...@@ -5,6 +5,8 @@ import type { SmartContract } from 'types/api/contract'; ...@@ -5,6 +5,8 @@ import type { SmartContract } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
interface Props { interface Props {
...@@ -59,23 +61,15 @@ const Sol2UmlDiagram = ({ addressHash }: Props) => { ...@@ -59,23 +61,15 @@ const Sol2UmlDiagram = ({ addressHash }: Props) => {
newWindow?.document.write(image.outerHTML); newWindow?.document.write(image.outerHTML);
}, [ imgUrl ]); }, [ imgUrl ]);
if (!addressHash) { throwOnAbsentParamError(addressHash);
throw Error('Contract address is not provided', { cause: { status: 404 } as unknown as Error }); throwOnResourceLoadError(contractQuery);
} throwOnResourceLoadError(umlQuery);
if (contractQuery.isError) {
throw Error('Contract fetch error', { cause: contractQuery.error as unknown as Error });
}
if (umlQuery.isError) {
throw Error('Uml diagram fetch error', { cause: contractQuery.error as unknown as Error });
}
if (contractQuery.isPending || umlQuery.isPending) { if (contractQuery.isPending || umlQuery.isPending) {
return <ContentLoader/>; return <ContentLoader/>;
} }
if (!umlQuery.data.svg) { if (!umlQuery.data?.svg || !contractQuery.data) {
return <span>No data for visualization</span>; return <span>No data for visualization</span>;
} }
......
...@@ -9,6 +9,7 @@ import type { TokenInfo } from 'types/api/token'; ...@@ -9,6 +9,7 @@ import type { TokenInfo } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import { TOKEN_COUNTERS } from 'stubs/token'; import { TOKEN_COUNTERS } from 'stubs/token';
import type { TokenTabs } from 'ui/pages/Token'; import type { TokenTabs } from 'ui/pages/Token';
...@@ -63,9 +64,7 @@ const TokenDetails = ({ tokenQuery }: Props) => { ...@@ -63,9 +64,7 @@ const TokenDetails = ({ tokenQuery }: Props) => {
); );
}, [ tokenCountersQuery.data, tokenCountersQuery.isPlaceholderData, changeUrlAndScroll ]); }, [ tokenCountersQuery.data, tokenCountersQuery.isPlaceholderData, changeUrlAndScroll ]);
if (tokenQuery.isError) { throwOnResourceLoadError(tokenQuery);
throw Error('Token fetch error', { cause: tokenQuery.error as unknown as Error });
}
const { const {
exchange_rate: exchangeRate, exchange_rate: exchangeRate,
......
...@@ -27,7 +27,7 @@ const TokenProjectInfo = ({ data }: Props) => { ...@@ -27,7 +27,7 @@ const TokenProjectInfo = ({ data }: Props) => {
if (isMobile) { if (isMobile) {
return ( return (
<> <>
<TriggerButton isOpen={ isOpen } onClick={ onToggle }/> <TriggerButton onClick={ onToggle }/>
<Modal isOpen={ isOpen } onClose={ onClose } size="full"> <Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent> <ModalContent>
<ModalCloseButton/> <ModalCloseButton/>
...@@ -41,7 +41,7 @@ const TokenProjectInfo = ({ data }: Props) => { ...@@ -41,7 +41,7 @@ const TokenProjectInfo = ({ data }: Props) => {
return ( return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy> <Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger> <PopoverTrigger>
<TriggerButton isOpen={ isOpen } onClick={ onToggle }/> <TriggerButton onClick={ onToggle }/>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent w="500px"> <PopoverContent w="500px">
<PopoverBody px={ 6 } py={ 5 }> <PopoverBody px={ 6 } py={ 5 }>
......
...@@ -5,10 +5,9 @@ import IconSvg from 'ui/shared/IconSvg'; ...@@ -5,10 +5,9 @@ import IconSvg from 'ui/shared/IconSvg';
interface Props { interface Props {
onClick: () => void; onClick: () => void;
isOpen: boolean;
} }
const TriggerButton = ({ isOpen, onClick }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => { const TriggerButton = ({ onClick }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
return ( return (
<Button <Button
ref={ ref } ref={ ref }
...@@ -23,7 +22,6 @@ const TriggerButton = ({ isOpen, onClick }: Props, ref: React.ForwardedRef<HTMLB ...@@ -23,7 +22,6 @@ const TriggerButton = ({ isOpen, onClick }: Props, ref: React.ForwardedRef<HTMLB
> >
<IconSvg name="rocket" boxSize={ 5 } mr={ 1 }/> <IconSvg name="rocket" boxSize={ 5 } mr={ 1 }/>
<span>Info</span> <span>Info</span>
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 1 }/>
</Button> </Button>
); );
}; };
......
...@@ -28,7 +28,7 @@ const TokenVerifiedInfo = ({ verifiedInfoQuery }: Props) => { ...@@ -28,7 +28,7 @@ const TokenVerifiedInfo = ({ verifiedInfoQuery }: Props) => {
<> <>
<Skeleton w="100px" h="30px" borderRadius="base"/> <Skeleton w="100px" h="30px" borderRadius="base"/>
<Skeleton w="100px" h="30px" borderRadius="base"/> <Skeleton w="100px" h="30px" borderRadius="base"/>
<Skeleton w="80px" h="30px" borderRadius="base"/> <Skeleton w="70px" h="30px" borderRadius="base"/>
</> </>
); );
} }
......
import {
Grid,
GridItem,
Text,
Box,
Link,
Spinner,
Flex,
Tooltip,
chakra,
useColorModeValue,
Skeleton,
Alert,
} from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import { scroller, Element } from 'react-scroll';
import { ZKEVM_L2_TX_STATUSES } from 'types/api/transaction'; import TestnetWarning from 'ui/shared/alerts/TestnetWarning';
import { route } from 'nextjs-routes'; import TxInfo from './details/TxInfo';
import type { TxQuery } from './useTxQuery';
import config from 'configs/app'; interface Props {
import { WEI, WEI_IN_GWEI } from 'lib/consts'; txQuery: TxQuery;
import dayjs from 'lib/date/dayjs'; }
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ZkEvmBatchEntityL2 from 'ui/shared/entities/block/ZkEvmBatchEntityL2';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LogDecodedInputData from 'ui/shared/logs/LogDecodedInputData';
import RawInputData from 'ui/shared/RawInputData';
import TxStatus from 'ui/shared/statusTag/TxStatus';
import TextSeparator from 'ui/shared/TextSeparator';
import TxFeeStability from 'ui/shared/tx/TxFeeStability';
import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
import TxDetailsTokenTransfers from 'ui/tx/details/TxDetailsTokenTransfers';
import TxDetailsWithdrawalStatus from 'ui/tx/details/TxDetailsWithdrawalStatus';
import TxRevertReason from 'ui/tx/details/TxRevertReason';
import TxAllowedPeekers from 'ui/tx/TxAllowedPeekers';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxDetails = () => {
const { data, isPlaceholderData, isError, socketStatus, error } = useFetchTxInfo();
const [ isExpanded, setIsExpanded ] = React.useState(false);
const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag);
scroller.scrollTo('TxDetails__cutLink', {
duration: 500,
smooth: true,
});
}, []);
const executionSuccessIconColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
if (isError) {
if (error?.status === 422) {
throw Error('Invalid tx hash', { cause: error as unknown as Error });
}
if (error?.status === 404) {
throw Error('Tx not found', { cause: error as unknown as Error });
}
return <DataFetchAlert/>;
}
if (!data) {
return null;
}
const addressFromTags = [
...data.from.private_tags || [],
...data.from.public_tags || [],
...data.from.watchlist_names || [],
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const toAddress = data.to ? data.to : data.created_contract;
const addressToTags = [
...toAddress?.private_tags || [],
...toAddress?.public_tags || [],
...toAddress?.watchlist_names || [],
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const executionSuccessBadge = toAddress?.is_contract && data.result === 'success' ? (
<Tooltip label="Contract execution completed">
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
<IconSvg name="status/success" boxSize={ 4 } color={ executionSuccessIconColor } cursor="pointer"/>
</chakra.span>
</Tooltip>
) : null;
const executionFailedBadge = toAddress?.is_contract && Boolean(data.status) && data.result !== 'success' ? (
<Tooltip label="Error occurred during contract execution">
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
<IconSvg name="status/error" boxSize={ 4 } color="error" cursor="pointer"/>
</chakra.span>
</Tooltip>
) : null;
const TxDetails = ({ txQuery }: Props) => {
return ( return (
<> <>
{ config.chain.isTestnet && ( <TestnetWarning mb={ 6 } isLoading={ txQuery.isPlaceholderData }/>
<Skeleton mb={ 6 } isLoaded={ !isPlaceholderData }> <TxInfo data={ txQuery.data } isLoading={ txQuery.isPlaceholderData } socketStatus={ txQuery.socketStatus }/>
<Alert status="warning">This is a testnet transaction only</Alert>
</Skeleton>
) }
<Grid columnGap={ 8 } rowGap={{ base: 3, lg: 3 }} templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}>
{ socketStatus && (
<GridItem colSpan={{ base: undefined, lg: 2 }} mb={ 2 }>
<TxSocketAlert status={ socketStatus }/>
</GridItem>
) }
<DetailsInfoItem
title="Transaction hash"
hint="Unique character string (TxID) assigned to every verified transaction"
flexWrap="nowrap"
isLoading={ isPlaceholderData }
>
{ data.status === null && <Spinner mr={ 2 } size="sm" flexShrink={ 0 }/> }
<Skeleton isLoaded={ !isPlaceholderData } overflow="hidden">
<HashStringShortenDynamic hash={ data.hash }/>
</Skeleton>
<CopyToClipboard text={ data.hash } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title={ config.features.zkEvmRollup.isEnabled ? 'L2 status and method' : 'Status and method' }
hint="Current transaction state: Success, Failed (Error), or Pending (In Process)"
isLoading={ isPlaceholderData }
>
<TxStatus status={ data.status } errorText={ data.status === 'error' ? data.result : undefined } isLoading={ isPlaceholderData }/>
{ data.method && (
<Tag colorScheme={ data.method === 'Multicall' ? 'teal' : 'gray' } isLoading={ isPlaceholderData } isTruncated ml={ 3 }>
{ data.method }
</Tag>
) }
</DetailsInfoItem>
{ config.features.optimisticRollup.isEnabled && data.op_withdrawals && data.op_withdrawals.length > 0 && (
<DetailsInfoItem
title="Withdrawal status"
hint="Detailed status progress of the transaction"
>
<Flex flexDir="column" rowGap={ 2 }>
{ data.op_withdrawals.map((withdrawal) => (
<Box key={ withdrawal.nonce }>
<Box mb={ 2 }>
<span>Nonce: </span>
<chakra.span fontWeight={ 600 }>{ withdrawal.nonce }</chakra.span>
</Box>
<TxDetailsWithdrawalStatus
status={ withdrawal.status }
l1TxHash={ withdrawal.l1_transaction_hash }
/>
</Box>
)) }
</Flex>
</DetailsInfoItem>
) }
{ data.zkevm_status && (
<DetailsInfoItem
title="Confirmation status"
hint="Status of the transaction confirmation path to L1"
isLoading={ isPlaceholderData }
>
<VerificationSteps currentStep={ data.zkevm_status } steps={ ZKEVM_L2_TX_STATUSES } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
) }
{ data.revert_reason && (
<DetailsInfoItem
title="Revert reason"
hint="The revert reason of the transaction"
>
<TxRevertReason { ...data.revert_reason }/>
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Block"
hint="Block number containing the transaction"
isLoading={ isPlaceholderData }
>
{ data.block === null ?
<Text>Pending</Text> : (
<BlockEntity
isLoading={ isPlaceholderData }
number={ data.block }
noIcon
/>
) }
{ Boolean(data.confirmations) && (
<>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isPlaceholderData } color="text_secondary">
<span>{ data.confirmations } Block confirmations</span>
</Skeleton>
</>
) }
</DetailsInfoItem>
{ data.zkevm_batch_number && (
<DetailsInfoItem
title="Tx batch"
hint="Batch index for this transaction"
isLoading={ isPlaceholderData }
>
<ZkEvmBatchEntityL2
isLoading={ isPlaceholderData }
number={ data.zkevm_batch_number }
/>
</DetailsInfoItem>
) }
{ data.timestamp && (
<DetailsInfoItem
title="Timestamp"
hint="Date & time of transaction inclusion, including length of time for confirmation"
isLoading={ isPlaceholderData }
>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 2 }>{ dayjs(data.timestamp).fromNow() }</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">{ dayjs(data.timestamp).format('llll') }</Skeleton>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isPlaceholderData } color="text_secondary">
<span>{ getConfirmationDuration(data.confirmation_duration) }</span>
</Skeleton>
</DetailsInfoItem>
) }
{ data.execution_node && (
<DetailsInfoItem
title="Kettle"
hint="Node that carried out the confidential computation"
isLoading={ isPlaceholderData }
>
<AddressEntity
address={ data.execution_node }
href={ route({ pathname: '/txs/kettle/[hash]', query: { hash: data.execution_node.hash } }) }
/>
</DetailsInfoItem>
) }
{ data.allowed_peekers && data.allowed_peekers.length > 0 && (
<TxAllowedPeekers items={ data.allowed_peekers }/>
) }
<DetailsSponsoredItem isLoading={ isPlaceholderData }/>
<DetailsInfoItemDivider/>
<TxDetailsActions hash={ data.hash } actions={ data.actions } isTxDataLoading={ isPlaceholderData }/>
<DetailsInfoItem
title="From"
hint="Address (external or contract) sending the transaction"
isLoading={ isPlaceholderData }
columnGap={ 3 }
>
<AddressEntity
address={ data.from }
isLoading={ isPlaceholderData }
/>
{ data.from.name && <Text>{ data.from.name }</Text> }
{ addressFromTags.length > 0 && (
<Flex columnGap={ 3 }>
{ addressFromTags }
</Flex>
) }
</DetailsInfoItem>
<DetailsInfoItem
title={ data.to?.is_contract ? 'Interacted with contract' : 'To' }
hint="Address (external or contract) receiving the transaction"
isLoading={ isPlaceholderData }
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
columnGap={ 3 }
>
{ toAddress ? (
<>
{ data.to && data.to.hash ? (
<Flex flexWrap="nowrap" alignItems="center" maxW="100%">
<AddressEntity
address={ toAddress }
isLoading={ isPlaceholderData }
/>
{ executionSuccessBadge }
{ executionFailedBadge }
</Flex>
) : (
<Flex width="100%" whiteSpace="pre" alignItems="center" flexShrink={ 0 }>
<span>[Contract </span>
<AddressEntity
address={ toAddress }
isLoading={ isPlaceholderData }
noIcon
/>
<span>created]</span>
{ executionSuccessBadge }
{ executionFailedBadge }
</Flex>
) }
{ addressToTags.length > 0 && (
<Flex columnGap={ 3 }>
{ addressToTags }
</Flex>
) }
</>
) : (
<span>[ Contract creation ]</span>
) }
</DetailsInfoItem>
{ data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash } isOverflow={ data.token_transfers_overflow }/> }
<DetailsInfoItemDivider/>
{ data.zkevm_sequence_hash && (
<DetailsInfoItem
title="Sequence tx hash"
flexWrap="nowrap"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData } overflow="hidden">
<HashStringShortenDynamic hash={ data.zkevm_sequence_hash }/>
</Skeleton>
<CopyToClipboard text={ data.zkevm_sequence_hash } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
) }
{ data.zkevm_verify_hash && (
<DetailsInfoItem
title="Verify tx hash"
flexWrap="nowrap"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData } overflow="hidden">
<HashStringShortenDynamic hash={ data.zkevm_verify_hash }/>
</Skeleton>
<CopyToClipboard text={ data.zkevm_verify_hash } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
) }
{ (data.zkevm_batch_number || data.zkevm_verify_hash) && <DetailsInfoItemDivider/> }
{ !config.UI.views.tx.hiddenFields?.value && (
<DetailsInfoItem
title="Value"
hint="Value sent in the native token (and USD) if applicable"
isLoading={ isPlaceholderData }
>
<CurrencyValue
value={ data.value }
currency={ config.chain.currency.symbol }
exchangeRate={ data.exchange_rate }
isLoading={ isPlaceholderData }
flexWrap="wrap"
/>
</DetailsInfoItem>
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<DetailsInfoItem
title="Transaction fee"
hint="Total transaction fee"
isLoading={ isPlaceholderData }
>
{ data.stability_fee ? (
<TxFeeStability data={ data.stability_fee } isLoading={ isPlaceholderData }/>
) : (
<CurrencyValue
value={ data.fee.value }
currency={ config.UI.views.tx.hiddenFields?.fee_currency ? '' : config.chain.currency.symbol }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
isLoading={ isPlaceholderData }
/>
) }
</DetailsInfoItem>
) }
<TxDetailsGasPrice gasPrice={ data.gas_price } isLoading={ isPlaceholderData }/>
<TxDetailsFeePerGas txFee={ data.fee.value } gasUsed={ data.gas_used } isLoading={ isPlaceholderData }/>
<DetailsInfoItem
title="Gas usage & limit by txn"
hint="Actual gas amount used by the transaction"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData }>{ BigNumber(data.gas_limit).toFormat() }</Skeleton>
<Utilization ml={ 4 } value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
{ !config.UI.views.tx.hiddenFields?.gas_fees &&
(data.base_fee_per_gas || data.max_fee_per_gas || data.max_priority_fee_per_gas) && (
<DetailsInfoItem
title="Gas fees (Gwei)"
// eslint-disable-next-line max-len
hint={ `
Base Fee refers to the network Base Fee at the time of the block,
while Max Fee & Max Priority Fee refer to the max amount a user is willing to pay
for their tx & to give to the ${ getNetworkValidatorTitle() } respectively
` }
isLoading={ isPlaceholderData }
>
{ data.base_fee_per_gas && (
<Skeleton isLoaded={ !isPlaceholderData }>
<Text as="span" fontWeight="500">Base: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.base_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
{ (data.max_fee_per_gas || data.max_priority_fee_per_gas) && <TextSeparator/> }
</Skeleton>
) }
{ data.max_fee_per_gas && (
<Box>
<Text as="span" fontWeight="500">Max: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
{ data.max_priority_fee_per_gas && <TextSeparator/> }
</Box>
) }
{ data.max_priority_fee_per_gas && (
<Box>
<Text as="span" fontWeight="500">Max priority: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
</Box>
) }
</DetailsInfoItem>
) }
{ data.tx_burnt_fee && !config.UI.views.tx.hiddenFields?.burnt_fees && !config.features.optimisticRollup.isEnabled && (
<DetailsInfoItem
title="Burnt fees"
hint={ `Amount of ${ config.chain.currency.symbol } 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={ config.chain.currency.symbol }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
ml={ 2 }
/>
</DetailsInfoItem>
) }
{ config.features.optimisticRollup.isEnabled && (
<>
{ data.l1_gas_used && (
<DetailsInfoItem
title="L1 gas used by txn"
hint="L1 gas used by transaction"
isLoading={ isPlaceholderData }
>
<Text>{ BigNumber(data.l1_gas_used).toFormat() }</Text>
</DetailsInfoItem>
) }
{ data.l1_gas_price && (
<DetailsInfoItem
title="L1 gas price"
hint="L1 gas price"
isLoading={ isPlaceholderData }
>
<Text mr={ 1 }>{ BigNumber(data.l1_gas_price).dividedBy(WEI).toFixed() } { config.chain.currency.symbol }</Text>
<Text variant="secondary">({ BigNumber(data.l1_gas_price).dividedBy(WEI_IN_GWEI).toFixed() } Gwei)</Text>
</DetailsInfoItem>
) }
{ data.l1_fee && (
<DetailsInfoItem
title="L1 fee"
// eslint-disable-next-line max-len
hint={ `L1 Data Fee which is used to cover the L1 "security" cost from the batch submission mechanism. In combination with L2 execution fee, L1 fee makes the total amount of fees that a transaction pays.` }
isLoading={ isPlaceholderData }
>
<CurrencyValue
value={ data.l1_fee }
currency={ config.chain.currency.symbol }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
/>
</DetailsInfoItem>
) }
{ data.l1_fee_scalar && (
<DetailsInfoItem
title="L1 fee scalar"
hint="A Dynamic overhead (fee scalar) premium, which serves as a buffer in case L1 prices rapidly increase."
isLoading={ isPlaceholderData }
>
<Text>{ data.l1_fee_scalar }</Text>
</DetailsInfoItem>
) }
</>
) }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Element name="TxDetails__cutLink">
<Skeleton isLoaded={ !isPlaceholderData } mt={ 6 } display="inline-block">
<Link
display="inline-block"
fontSize="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
onClick={ handleCutClick }
>
{ isExpanded ? 'Hide details' : 'View details' }
</Link>
</Skeleton>
</Element>
</GridItem>
{ isExpanded && (
<>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
<TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position }/>
<DetailsInfoItem
title="Raw input"
hint="Binary data included with the transaction. See logs tab for additional info"
>
<RawInputData hex={ data.raw_input }/>
</DetailsInfoItem>
{ data.decoded_input && (
<DetailsInfoItem
title="Decoded input data"
hint="Decoded input data"
>
<LogDecodedInputData data={ data.decoded_input }/>
</DetailsInfoItem>
) }
</>
) }
</Grid>
</> </>
); );
}; };
export default TxDetails; export default React.memo(TxDetails);
import { Flex } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { Chain, GetBlockReturnType, GetTransactionReturnType, TransactionReceipt } from 'viem';
import type { Transaction } from 'types/api/transaction';
import { SECOND } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import hexToDecimal from 'lib/hexToDecimal';
import { publicClient } from 'lib/web3/client';
import { GET_BLOCK, GET_TRANSACTION, GET_TRANSACTION_RECEIPT, GET_TRANSACTION_CONFIRMATIONS } from 'stubs/RPC';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
import TestnetWarning from 'ui/shared/alerts/TestnetWarning';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TxInfo from './details/TxInfo';
import type { TxQuery } from './useTxQuery';
type RpcResponseType = [
GetTransactionReturnType<Chain, 'latest'>,
TransactionReceipt | null,
bigint | null,
GetBlockReturnType<Chain, false, 'latest'> | null,
];
interface Props {
hash: string;
txQuery: TxQuery;
}
const TxDetailsDegraded = ({ hash, txQuery }: Props) => {
const [ originalError ] = React.useState(txQuery.error);
const query = useQuery<RpcResponseType, unknown, Transaction | null>({
queryKey: [ 'RPC', 'tx', { hash } ],
queryFn: async() => {
const tx = await publicClient.getTransaction({ hash: hash as `0x${ string }` });
if (!tx) {
throw new Error('Not found');
}
const txReceipt = await publicClient.getTransactionReceipt({ hash: hash as `0x${ string }` }).catch(() => null);
const block = await publicClient.getBlock({ blockHash: tx.blockHash }).catch(() => null);
const latestBlock = await publicClient.getBlock().catch(() => null);
const confirmations = latestBlock && block ? latestBlock.number - block.number + BigInt(1) : null;
return [
tx,
txReceipt,
confirmations,
block,
];
},
select: (response) => {
const [ tx, txReceipt, txConfirmations, block ] = response;
const status = (() => {
if (!txReceipt) {
return null;
}
return txReceipt.status === 'success' ? 'ok' : 'error';
})();
const gasPrice = txReceipt?.effectiveGasPrice ?? tx.gasPrice;
const unknownAddress = {
is_contract: false,
is_verified: false,
implementation_name: '',
name: '',
private_tags: [],
public_tags: [],
watchlist_names: [],
ens_domain_name: null,
};
return {
from: { ...unknownAddress, hash: tx.from as string },
to: tx.to ? { ...unknownAddress, hash: tx.to as string } : null,
hash: tx.hash as string,
timestamp: block?.timestamp ? dayjs.unix(Number(block.timestamp)).format() : null,
confirmation_duration: null,
status,
block: tx.blockNumber ? Number(tx.blockNumber) : null,
value: tx.value.toString(),
gas_price: txReceipt?.effectiveGasPrice.toString() ?? tx.gasPrice?.toString() ?? null,
base_fee_per_gas: block?.baseFeePerGas?.toString() ?? null,
max_fee_per_gas: tx.maxFeePerGas?.toString() ?? null,
max_priority_fee_per_gas: tx.maxPriorityFeePerGas?.toString() ?? null,
nonce: tx.nonce,
position: tx.transactionIndex,
type: tx.typeHex ? hexToDecimal(tx.typeHex) : null,
raw_input: tx.input,
gas_used: txReceipt?.gasUsed?.toString() ?? null,
gas_limit: tx.gas.toString(),
confirmations: txConfirmations && txConfirmations > 0 ? Number(txConfirmations) : 0,
fee: {
value: txReceipt && gasPrice ? (txReceipt.gasUsed * gasPrice).toString() : null,
type: 'actual',
},
created_contract: txReceipt?.contractAddress ?
{ ...unknownAddress, hash: txReceipt.contractAddress, is_contract: true } :
null,
result: '',
priority_fee: null,
tx_burnt_fee: null,
revert_reason: null,
decoded_input: null,
has_error_in_internal_txs: null,
token_transfers: null,
token_transfers_overflow: false,
exchange_rate: null,
method: null,
tx_types: [],
tx_tag: null,
actions: [],
};
},
placeholderData: [
GET_TRANSACTION,
GET_TRANSACTION_RECEIPT,
GET_TRANSACTION_CONFIRMATIONS,
GET_BLOCK,
],
refetchOnMount: false,
enabled: !txQuery.isPlaceholderData,
retry: 2,
retryDelay: 5 * SECOND,
});
const hasData = Boolean(query.data);
React.useEffect(() => {
if (!query.isPlaceholderData && hasData) {
txQuery.setRefetchOnError.on();
}
}, [ hasData, query.isPlaceholderData, txQuery ]);
React.useEffect(() => {
return () => {
txQuery.setRefetchOnError.off();
};
}, [ txQuery.setRefetchOnError ]);
if (!query.data) {
if (originalError?.status === 404) {
throw Error('Not found', { cause: { status: 404 } as unknown as Error });
}
return <DataFetchAlert/>;
}
return (
<>
<Flex rowGap={ 2 } mb={ 6 } flexDir="column">
<TestnetWarning isLoading={ query.isPlaceholderData }/>
{ originalError?.status !== 404 && <ServiceDegradationWarning isLoading={ query.isPlaceholderData }/> }
</Flex>
<TxInfo data={ query.data } isLoading={ query.isPlaceholderData }/>
</>
);
};
export default React.memo(TxDetailsDegraded);
...@@ -5,7 +5,7 @@ import React from 'react'; ...@@ -5,7 +5,7 @@ import React from 'react';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
import type { ExcludeUndefined } from 'types/utils'; import type { ExcludeUndefined } from 'types/utils';
import config from 'configs/app'; import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
...@@ -61,7 +61,7 @@ const TxDetailsWrapped = ({ data }: Props) => { ...@@ -61,7 +61,7 @@ const TxDetailsWrapped = ({ data }: Props) => {
> >
<CurrencyValue <CurrencyValue
value={ data.value } value={ data.value }
currency={ config.chain.currency.symbol } currency={ currencyUnits.ether }
flexWrap="wrap" flexWrap="wrap"
/> />
</DetailsInfoItem> </DetailsInfoItem>
...@@ -72,7 +72,7 @@ const TxDetailsWrapped = ({ data }: Props) => { ...@@ -72,7 +72,7 @@ const TxDetailsWrapped = ({ data }: Props) => {
> >
<CurrencyValue <CurrencyValue
value={ data.fee.value } value={ data.fee.value }
currency={ config.chain.currency.symbol } currency={ currencyUnits.ether }
flexWrap="wrap" flexWrap="wrap"
/> />
</DetailsInfoItem> </DetailsInfoItem>
......
...@@ -7,9 +7,9 @@ import TestApp from 'playwright/TestApp'; ...@@ -7,9 +7,9 @@ import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import TxInternals from './TxInternals'; import TxInternals from './TxInternals';
import type { TxQuery } from './useTxQuery';
const TX_HASH = txMock.base.hash; const TX_HASH = txMock.base.hash;
const API_URL_TX = buildApiUrl('tx', { hash: TX_HASH });
const API_URL_TX_INTERNALS = buildApiUrl('tx_internal_txs', { hash: TX_HASH }); const API_URL_TX_INTERNALS = buildApiUrl('tx_internal_txs', { hash: TX_HASH });
const hooksConfig = { const hooksConfig = {
router: { router: {
...@@ -18,18 +18,20 @@ const hooksConfig = { ...@@ -18,18 +18,20 @@ const hooksConfig = {
}; };
test('base view +@mobile', async({ mount, page }) => { test('base view +@mobile', async({ mount, page }) => {
await page.route(API_URL_TX, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.base),
}));
await page.route(API_URL_TX_INTERNALS, (route) => route.fulfill({ await page.route(API_URL_TX_INTERNALS, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify(internalTxsMock.baseResponse), body: JSON.stringify(internalTxsMock.baseResponse),
})); }));
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxInternals/> <TxInternals txQuery={ txQuery }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
......
...@@ -3,7 +3,6 @@ import React from 'react'; ...@@ -3,7 +3,6 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransaction } from 'types/api/internalTransaction';
import { SECOND } from 'lib/consts';
// import { apos } from 'lib/html-entities'; // import { apos } from 'lib/html-entities';
import { INTERNAL_TX } from 'stubs/internalTx'; import { INTERNAL_TX } from 'stubs/internalTx';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
...@@ -19,7 +18,8 @@ import TxInternalsTable from 'ui/tx/internals/TxInternalsTable'; ...@@ -19,7 +18,8 @@ import TxInternalsTable from 'ui/tx/internals/TxInternalsTable';
import type { Sort, SortField } from 'ui/tx/internals/utils'; import type { Sort, SortField } from 'ui/tx/internals/utils';
import TxPendingAlert from 'ui/tx/TxPendingAlert'; import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import type { TxQuery } from './useTxQuery';
const SORT_SEQUENCE: Record<SortField, Array<Sort | undefined>> = { const SORT_SEQUENCE: Record<SortField, Array<Sort | undefined>> = {
value: [ 'value-desc', 'value-asc', undefined ], value: [ 'value-desc', 'value-asc', undefined ],
...@@ -62,17 +62,20 @@ const sortFn = (sort: Sort | undefined) => (a: InternalTransaction, b: InternalT ...@@ -62,17 +62,20 @@ const sortFn = (sort: Sort | undefined) => (a: InternalTransaction, b: InternalT
// item.to.hash.toLowerCase().includes(formattedSearchTerm); // item.to.hash.toLowerCase().includes(formattedSearchTerm);
// }; // };
const TxInternals = () => { interface Props {
txQuery: TxQuery;
}
const TxInternals = ({ txQuery }: Props) => {
// filters are not implemented yet in api // filters are not implemented yet in api
// const [ filters, setFilters ] = React.useState<Array<TxInternalsType>>([]); // const [ filters, setFilters ] = React.useState<Array<TxInternalsType>>([]);
// const [ searchTerm, setSearchTerm ] = React.useState<string>(''); // const [ searchTerm, setSearchTerm ] = React.useState<string>('');
const [ sort, setSort ] = React.useState<Sort>(); const [ sort, setSort ] = React.useState<Sort>();
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({ const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_internal_txs', resourceName: 'tx_internal_txs',
pathParams: { hash: txInfo.data?.hash }, pathParams: { hash: txQuery.data?.hash },
options: { options: {
enabled: !txInfo.isPlaceholderData && Boolean(txInfo.data?.hash) && Boolean(txInfo.data?.status), enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: generateListStub<'tx_internal_txs'>(INTERNAL_TX, 3, { next_page_params: null }), placeholderData: generateListStub<'tx_internal_txs'>(INTERNAL_TX, 3, { next_page_params: null }),
}, },
}); });
...@@ -90,8 +93,8 @@ const TxInternals = () => { ...@@ -90,8 +93,8 @@ const TxInternals = () => {
}; };
}, [ isPlaceholderData ]); }, [ isPlaceholderData ]);
if (!txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data?.status) { if (!txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data?.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>; return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
} }
const filteredData = data?.items const filteredData = data?.items
...@@ -125,7 +128,7 @@ const TxInternals = () => { ...@@ -125,7 +128,7 @@ const TxInternals = () => {
return ( return (
<DataListDisplay <DataListDisplay
isError={ isError || txInfo.isError } isError={ isError || txQuery.isError }
items={ data?.items } items={ data?.items }
emptyText="There are no internal transactions for this transaction." emptyText="There are no internal transactions for this transaction."
// filterProps={{ // filterProps={{
......
import { Box, Text } from '@chakra-ui/react'; import { Box, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { SECOND } from 'lib/consts'; import type { Log } from 'types/api/log';
import { LOG } from 'stubs/log'; import { LOG } from 'stubs/log';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
...@@ -11,28 +12,43 @@ import Pagination from 'ui/shared/pagination/Pagination'; ...@@ -11,28 +12,43 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxPendingAlert from 'ui/tx/TxPendingAlert'; import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxLogs = () => { import type { TxQuery } from './useTxQuery';
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
interface Props {
txQuery: TxQuery;
logsFilter?: (log: Log) => boolean;
}
const TxLogs = ({ txQuery, logsFilter }: Props) => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({ const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_logs', resourceName: 'tx_logs',
pathParams: { hash: txInfo.data?.hash }, pathParams: { hash: txQuery.data?.hash },
options: { options: {
enabled: !txInfo.isPlaceholderData && Boolean(txInfo.data?.hash) && Boolean(txInfo.data?.status), enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: generateListStub<'tx_logs'>(LOG, 3, { next_page_params: null }), placeholderData: generateListStub<'tx_logs'>(LOG, 3, { next_page_params: null }),
}, },
}); });
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) { if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>; return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
} }
if (isError || txInfo.isError) { if (isError || txQuery.isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
if (!data?.items.length) { let items: Array<Log> = [];
if (data?.items) {
if (isPlaceholderData) {
items = data?.items;
} else {
items = logsFilter ? data.items.filter(logsFilter) : data.items;
}
}
if (!items.length) {
return <Text as="span">There are no logs for this transaction.</Text>; return <Text as="span">There are no logs for this transaction.</Text>;
} }
...@@ -43,7 +59,7 @@ const TxLogs = () => { ...@@ -43,7 +59,7 @@ const TxLogs = () => {
<Pagination ml="auto" { ...pagination }/> <Pagination ml="auto" { ...pagination }/>
</ActionBar> </ActionBar>
) } ) }
{ data?.items.map((item, index) => <LogItem key={ index } { ...item } type="transaction" isLoading={ isPlaceholderData }/>) } { items.map((item, index) => <LogItem key={ index } { ...item } type="transaction" isLoading={ isPlaceholderData }/>) }
</Box> </Box>
); );
}; };
......
...@@ -5,7 +5,6 @@ import type { SocketMessage } from 'lib/socket/types'; ...@@ -5,7 +5,6 @@ import type { SocketMessage } from 'lib/socket/types';
import type { RawTracesResponse } from 'types/api/rawTrace'; import type { RawTracesResponse } from 'types/api/rawTrace';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { SECOND } from 'lib/consts';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage'; import useSocketMessage from 'lib/socket/useSocketMessage';
...@@ -14,19 +13,23 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -14,19 +13,23 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import RawDataSnippet from 'ui/shared/RawDataSnippet'; import RawDataSnippet from 'ui/shared/RawDataSnippet';
import TxPendingAlert from 'ui/tx/TxPendingAlert'; import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxRawTrace = () => { import type { TxQuery } from './useTxQuery';
interface Props {
txQuery: TxQuery;
}
const TxRawTrace = ({ txQuery }: Props) => {
const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false); const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false);
const [ rawTraces, setRawTraces ] = React.useState<RawTracesResponse>(); const [ rawTraces, setRawTraces ] = React.useState<RawTracesResponse>();
const router = useRouter(); const router = useRouter();
const hash = getQueryParamString(router.query.hash); const hash = getQueryParamString(router.query.hash);
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const { data, isPlaceholderData, isError } = useApiQuery('tx_raw_trace', { const { data, isPlaceholderData, isError } = useApiQuery('tx_raw_trace', {
pathParams: { hash }, pathParams: { hash },
queryOptions: { queryOptions: {
enabled: Boolean(hash) && Boolean(txInfo.data?.status) && isQueryEnabled, enabled: Boolean(hash) && Boolean(txQuery.data?.status) && isQueryEnabled,
placeholderData: TX_RAW_TRACE, placeholderData: TX_RAW_TRACE,
}, },
}); });
...@@ -39,7 +42,7 @@ const TxRawTrace = () => { ...@@ -39,7 +42,7 @@ const TxRawTrace = () => {
const channel = useSocketChannel({ const channel = useSocketChannel({
topic: `transactions:${ hash }`, topic: `transactions:${ hash }`,
isDisabled: !hash || txInfo.isPlaceholderData || !txInfo.data?.status, isDisabled: !hash || txQuery.isPlaceholderData || !txQuery.data?.status,
onJoin: enableQuery, onJoin: enableQuery,
onSocketError: enableQuery, onSocketError: enableQuery,
}); });
...@@ -49,11 +52,11 @@ const TxRawTrace = () => { ...@@ -49,11 +52,11 @@ const TxRawTrace = () => {
handler: handleRawTraceMessage, handler: handleRawTraceMessage,
}); });
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) { if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>; return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
} }
if (isError || txInfo.isError) { if (isError || txQuery.isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
......
...@@ -7,8 +7,8 @@ import TestApp from 'playwright/TestApp'; ...@@ -7,8 +7,8 @@ import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import TxState from './TxState'; import TxState from './TxState';
import type { TxQuery } from './useTxQuery';
const TX_INFO_API_URL = buildApiUrl('tx', { hash: txMock.base.hash });
const TX_STATE_API_URL = buildApiUrl('tx_state_changes', { hash: txMock.base.hash }); const TX_STATE_API_URL = buildApiUrl('tx_state_changes', { hash: txMock.base.hash });
const hooksConfig = { const hooksConfig = {
router: { router: {
...@@ -17,18 +17,20 @@ const hooksConfig = { ...@@ -17,18 +17,20 @@ const hooksConfig = {
}; };
test('base view +@mobile', async({ mount, page }) => { test('base view +@mobile', async({ mount, page }) => {
await page.route(TX_INFO_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.base),
}));
await page.route(TX_STATE_API_URL, (route) => route.fulfill({ await page.route(TX_STATE_API_URL, (route) => route.fulfill({
status: 200, status: 200,
body: JSON.stringify(txStateMock.baseResponse), body: JSON.stringify(txStateMock.baseResponse),
})); }));
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxState/> <TxState txQuery={ txQuery }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
......
import { Accordion, Hide, Show, Text } from '@chakra-ui/react'; import { Accordion, Hide, Show, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { SECOND } from 'lib/consts';
import { TX_STATE_CHANGES } from 'stubs/txStateChanges'; import { TX_STATE_CHANGES } from 'stubs/txStateChanges';
import ActionBar from 'ui/shared/ActionBar'; import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
...@@ -9,18 +8,21 @@ import Pagination from 'ui/shared/pagination/Pagination'; ...@@ -9,18 +8,21 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages'; import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxStateList from 'ui/tx/state/TxStateList'; import TxStateList from 'ui/tx/state/TxStateList';
import TxStateTable from 'ui/tx/state/TxStateTable'; import TxStateTable from 'ui/tx/state/TxStateTable';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import TxPendingAlert from './TxPendingAlert'; import TxPendingAlert from './TxPendingAlert';
import TxSocketAlert from './TxSocketAlert'; import TxSocketAlert from './TxSocketAlert';
import type { TxQuery } from './useTxQuery';
const TxState = () => { interface Props {
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND }); txQuery: TxQuery;
}
const TxState = ({ txQuery }: Props) => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({ const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_state_changes', resourceName: 'tx_state_changes',
pathParams: { hash: txInfo.data?.hash }, pathParams: { hash: txQuery.data?.hash },
options: { options: {
enabled: !txInfo.isPlaceholderData && Boolean(txInfo.data?.hash) && Boolean(txInfo.data?.status), enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: { placeholderData: {
items: TX_STATE_CHANGES, items: TX_STATE_CHANGES,
next_page_params: { next_page_params: {
...@@ -31,8 +33,8 @@ const TxState = () => { ...@@ -31,8 +33,8 @@ const TxState = () => {
}, },
}); });
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) { if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>; return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
} }
const content = data ? ( const content = data ? (
...@@ -54,12 +56,14 @@ const TxState = () => { ...@@ -54,12 +56,14 @@ const TxState = () => {
return ( return (
<> <>
<Text mb={ 6 }> { !isError && !txQuery.isError && (
A set of information that represents the current state is updated when a transaction takes place on the network. <Text mb={ 6 }>
The below is a summary of those changes. A set of information that represents the current state is updated when a transaction takes place on the network.
</Text> The below is a summary of those changes.
</Text>
) }
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError || txQuery.isError }
items={ data?.items } items={ data?.items }
emptyText="There are no state changes for this transaction." emptyText="There are no state changes for this transaction."
content={ content } content={ content }
......
...@@ -3,8 +3,8 @@ import { useRouter } from 'next/router'; ...@@ -3,8 +3,8 @@ import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/token'; import type { TokenType } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import { SECOND } from 'lib/consts';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes'; import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
...@@ -19,22 +19,26 @@ import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList'; ...@@ -19,22 +19,26 @@ import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable'; import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import TxPendingAlert from 'ui/tx/TxPendingAlert'; import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import type { TxQuery } from './useTxQuery';
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS); const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS);
const TxTokenTransfer = () => { interface Props {
const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND }); txQuery: TxQuery;
tokenTransferFilter?: (data: TokenTransfer) => boolean;
}
const TxTokenTransfer = ({ txQuery, tokenTransferFilter }: Props) => {
const router = useRouter(); const router = useRouter();
const [ typeFilter, setTypeFilter ] = React.useState<Array<TokenType>>(getTokenFilterValue(router.query.type) || []); const [ typeFilter, setTypeFilter ] = React.useState<Array<TokenType>>(getTokenFilterValue(router.query.type) || []);
const tokenTransferQuery = useQueryWithPages({ const tokenTransferQuery = useQueryWithPages({
resourceName: 'tx_token_transfers', resourceName: 'tx_token_transfers',
pathParams: { hash: txsInfo.data?.hash.toString() }, pathParams: { hash: txQuery.data?.hash.toString() },
options: { options: {
enabled: !txsInfo.isPlaceholderData && Boolean(txsInfo.data?.status && txsInfo.data?.hash), enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.status && txQuery.data?.hash),
placeholderData: getTokenTransfersStub(), placeholderData: getTokenTransfersStub(),
}, },
filters: { type: typeFilter }, filters: { type: typeFilter },
...@@ -45,24 +49,34 @@ const TxTokenTransfer = () => { ...@@ -45,24 +49,34 @@ const TxTokenTransfer = () => {
setTypeFilter(nextValue); setTypeFilter(nextValue);
}, [ tokenTransferQuery ]); }, [ tokenTransferQuery ]);
if (!txsInfo.isPending && !txsInfo.isPlaceholderData && !txsInfo.isError && !txsInfo.data.status) { if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txsInfo.socketStatus ? <TxSocketAlert status={ txsInfo.socketStatus }/> : <TxPendingAlert/>; return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
} }
if (txsInfo.isError || tokenTransferQuery.isError) { if (txQuery.isError || tokenTransferQuery.isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
const numActiveFilters = typeFilter.length; const numActiveFilters = typeFilter.length;
const isActionBarHidden = !numActiveFilters && !tokenTransferQuery.data?.items.length; const isActionBarHidden = !numActiveFilters && !tokenTransferQuery.data?.items.length;
let items: Array<TokenTransfer> = [];
if (tokenTransferQuery.data?.items) {
if (tokenTransferQuery.isPlaceholderData) {
items = tokenTransferQuery.data?.items;
} else {
items = tokenTransferFilter ? tokenTransferQuery.data.items.filter(tokenTransferFilter) : tokenTransferQuery.data.items;
}
}
const content = tokenTransferQuery.data?.items ? ( const content = tokenTransferQuery.data?.items ? (
<> <>
<Hide below="lg" ssr={ false }> <Hide below="lg" ssr={ false }>
<TokenTransferTable data={ tokenTransferQuery.data?.items } top={ isActionBarHidden ? 0 : 80 } isLoading={ tokenTransferQuery.isPlaceholderData }/> <TokenTransferTable data={ items } top={ isActionBarHidden ? 0 : 80 } isLoading={ tokenTransferQuery.isPlaceholderData }/>
</Hide> </Hide>
<Show below="lg" ssr={ false }> <Show below="lg" ssr={ false }>
<TokenTransferList data={ tokenTransferQuery.data?.items } isLoading={ tokenTransferQuery.isPlaceholderData }/> <TokenTransferList data={ items } isLoading={ tokenTransferQuery.isPlaceholderData }/>
</Show> </Show>
</> </>
) : null; ) : null;
...@@ -81,8 +95,8 @@ const TxTokenTransfer = () => { ...@@ -81,8 +95,8 @@ const TxTokenTransfer = () => {
return ( return (
<DataListDisplay <DataListDisplay
isError={ txsInfo.isError || tokenTransferQuery.isError } isError={ txQuery.isError || tokenTransferQuery.isError }
items={ tokenTransferQuery.data?.items } items={ items }
emptyText="There are no token transfers." emptyText="There are no token transfers."
filterProps={{ filterProps={{
emptyFilteredText: `Couldn${ apos }t find any token transfer that matches your query.`, emptyFilteredText: `Couldn${ apos }t find any token transfer that matches your query.`,
......
import React from 'react';
import { USER_OPS_ITEM } from 'stubs/userOps';
import { generateListStub } from 'stubs/utils';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import UserOpsContent from 'ui/userOps/UserOpsContent';
import type { TxQuery } from './useTxQuery';
interface Props {
txQuery: TxQuery;
}
const TxUserOps = ({ txQuery }: Props) => {
const userOpsQuery = useQueryWithPages({
resourceName: 'user_ops',
options: {
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.status && txQuery.data?.hash),
// most often there is only one user op in one tx
placeholderData: generateListStub<'user_ops'>(USER_OPS_ITEM, 1, { next_page_params: null }),
},
filters: { transaction_hash: txQuery.data?.hash },
});
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
return <UserOpsContent query={ userOpsQuery } showTx={ false }/>;
};
export default TxUserOps;
...@@ -3,6 +3,7 @@ import BigNumber from 'bignumber.js'; ...@@ -3,6 +3,7 @@ import BigNumber from 'bignumber.js';
import React from 'react'; import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import { currencyUnits } from 'lib/units';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
interface Props { interface Props {
...@@ -24,7 +25,7 @@ const TxDetailsFeePerGas = ({ txFee, gasUsed, isLoading }: Props) => { ...@@ -24,7 +25,7 @@ const TxDetailsFeePerGas = ({ txFee, gasUsed, isLoading }: Props) => {
> >
<Skeleton isLoaded={ !isLoading } mr={ 1 }> <Skeleton isLoaded={ !isLoading } mr={ 1 }>
{ BigNumber(txFee).dividedBy(10 ** config.chain.currency.decimals).dividedBy(gasUsed).toFixed() } { BigNumber(txFee).dividedBy(10 ** config.chain.currency.decimals).dividedBy(gasUsed).toFixed() }
{ config.UI.views.tx.hiddenFields?.fee_currency ? '' : ` ${ config.chain.currency.symbol }` } { config.UI.views.tx.hiddenFields?.fee_currency ? '' : ` ${ currencyUnits.ether }` }
</Skeleton> </Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
); );
......
...@@ -4,15 +4,16 @@ import React from 'react'; ...@@ -4,15 +4,16 @@ import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import { WEI, WEI_IN_GWEI } from 'lib/consts'; import { WEI, WEI_IN_GWEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
interface Props { interface Props {
gasPrice: string; gasPrice: string | null;
isLoading?: boolean; isLoading?: boolean;
} }
const TxDetailsGasPrice = ({ gasPrice, isLoading }: Props) => { const TxDetailsGasPrice = ({ gasPrice, isLoading }: Props) => {
if (config.UI.views.tx.hiddenFields?.gas_price) { if (config.UI.views.tx.hiddenFields?.gas_price || !gasPrice) {
return null; return null;
} }
...@@ -23,10 +24,10 @@ const TxDetailsGasPrice = ({ gasPrice, isLoading }: Props) => { ...@@ -23,10 +24,10 @@ const TxDetailsGasPrice = ({ gasPrice, isLoading }: Props) => {
isLoading={ isLoading } isLoading={ isLoading }
> >
<Skeleton isLoaded={ !isLoading } mr={ 1 }> <Skeleton isLoaded={ !isLoading } mr={ 1 }>
{ BigNumber(gasPrice).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } { BigNumber(gasPrice).dividedBy(WEI).toFixed() } { currencyUnits.ether }
</Skeleton> </Skeleton>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>({ BigNumber(gasPrice).dividedBy(WEI_IN_GWEI).toFixed() } Gwei)</span> <span>({ BigNumber(gasPrice).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })</span>
</Skeleton> </Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
); );
......
...@@ -4,12 +4,10 @@ import React from 'react'; ...@@ -4,12 +4,10 @@ import React from 'react';
import * as txMock from 'mocks/txs/tx'; import * as txMock from 'mocks/txs/tx';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs'; import * as configs from 'playwright/utils/configs';
import TxDetails from './TxDetails'; import TxInfo from './TxInfo';
const API_URL = buildApiUrl('tx', { hash: '1' });
const hooksConfig = { const hooksConfig = {
router: { router: {
query: { hash: 1 }, query: { hash: 1 },
...@@ -17,14 +15,9 @@ const hooksConfig = { ...@@ -17,14 +15,9 @@ const hooksConfig = {
}; };
test('between addresses +@mobile +@dark-mode', async({ mount, page }) => { test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.base),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.base } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -38,14 +31,9 @@ test('between addresses +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -38,14 +31,9 @@ test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
}); });
test('creating contact', async({ mount, page }) => { test('creating contact', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withContractCreation),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.withContractCreation } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -57,14 +45,9 @@ test('creating contact', async({ mount, page }) => { ...@@ -57,14 +45,9 @@ test('creating contact', async({ mount, page }) => {
}); });
test('with token transfer +@mobile', async({ mount, page }) => { test('with token transfer +@mobile', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withTokenTransfer),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -76,14 +59,9 @@ test('with token transfer +@mobile', async({ mount, page }) => { ...@@ -76,14 +59,9 @@ test('with token transfer +@mobile', async({ mount, page }) => {
}); });
test('with decoded revert reason', async({ mount, page }) => { test('with decoded revert reason', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withDecodedRevertReason),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.withDecodedRevertReason } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -95,14 +73,9 @@ test('with decoded revert reason', async({ mount, page }) => { ...@@ -95,14 +73,9 @@ test('with decoded revert reason', async({ mount, page }) => {
}); });
test('with decoded raw reason', async({ mount, page }) => { test('with decoded raw reason', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withRawRevertReason),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.withRawRevertReason } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -114,14 +87,9 @@ test('with decoded raw reason', async({ mount, page }) => { ...@@ -114,14 +87,9 @@ test('with decoded raw reason', async({ mount, page }) => {
}); });
test('pending', async({ mount, page }) => { test('pending', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.pending),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.pending } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -135,14 +103,9 @@ test('pending', async({ mount, page }) => { ...@@ -135,14 +103,9 @@ test('pending', async({ mount, page }) => {
}); });
test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => { test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withActionsUniswap),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.withActionsUniswap } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -159,14 +122,9 @@ const l2Test = test.extend({ ...@@ -159,14 +122,9 @@ const l2Test = test.extend({
}); });
l2Test('l2', async({ mount, page }) => { l2Test('l2', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.l2tx),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -185,14 +143,9 @@ const mainnetTest = test.extend({ ...@@ -185,14 +143,9 @@ const mainnetTest = test.extend({
}); });
mainnetTest('without testnet warning', async({ mount, page }) => { mainnetTest('without testnet warning', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.l2tx),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -209,14 +162,9 @@ const stabilityTest = test.extend({ ...@@ -209,14 +162,9 @@ const stabilityTest = test.extend({
}); });
stabilityTest('stability customization', async({ mount, page }) => { stabilityTest('stability customization', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.stabilityTx),
}));
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<TxDetails/> <TxInfo data={ txMock.stabilityTx } isLoading={ false }/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
......
import {
Box,
Grid,
GridItem,
Text,
Link,
Spinner,
Flex,
Tooltip,
chakra,
useColorModeValue,
Skeleton,
} from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import type { Transaction } from 'types/api/transaction';
import { ZKEVM_L2_TX_STATUSES } from 'types/api/transaction';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ZkEvmBatchEntityL2 from 'ui/shared/entities/block/ZkEvmBatchEntityL2';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LogDecodedInputData from 'ui/shared/logs/LogDecodedInputData';
import RawInputData from 'ui/shared/RawInputData';
import TxStatus from 'ui/shared/statusTag/TxStatus';
import TextSeparator from 'ui/shared/TextSeparator';
import TxFeeStability from 'ui/shared/tx/TxFeeStability';
import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
import TxDetailsTokenTransfers from 'ui/tx/details/TxDetailsTokenTransfers';
import TxDetailsWithdrawalStatus from 'ui/tx/details/TxDetailsWithdrawalStatus';
import TxRevertReason from 'ui/tx/details/TxRevertReason';
import TxAllowedPeekers from 'ui/tx/TxAllowedPeekers';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
interface Props {
data: Transaction | undefined;
isLoading: boolean;
socketStatus?: 'close' | 'error';
}
const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
const [ isExpanded, setIsExpanded ] = React.useState(false);
const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag);
scroller.scrollTo('TxInfo__cutLink', {
duration: 500,
smooth: true,
});
}, []);
const executionSuccessIconColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
if (!data) {
return null;
}
const addressFromTags = [
...data.from.private_tags || [],
...data.from.public_tags || [],
...data.from.watchlist_names || [],
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const toAddress = data.to ? data.to : data.created_contract;
const addressToTags = [
...toAddress?.private_tags || [],
...toAddress?.public_tags || [],
...toAddress?.watchlist_names || [],
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const executionSuccessBadge = toAddress?.is_contract && data.result === 'success' ? (
<Tooltip label="Contract execution completed">
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
<IconSvg name="status/success" boxSize={ 4 } color={ executionSuccessIconColor } cursor="pointer"/>
</chakra.span>
</Tooltip>
) : null;
const executionFailedBadge = toAddress?.is_contract && Boolean(data.status) && data.result !== 'success' ? (
<Tooltip label="Error occurred during contract execution">
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
<IconSvg name="status/error" boxSize={ 4 } color="error" cursor="pointer"/>
</chakra.span>
</Tooltip>
) : null;
return (
<Grid columnGap={ 8 } rowGap={{ base: 3, lg: 3 }} templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}>
{ socketStatus && (
<GridItem colSpan={{ base: undefined, lg: 2 }} mb={ 2 }>
<TxSocketAlert status={ socketStatus }/>
</GridItem>
) }
<DetailsInfoItem
title="Transaction hash"
hint="Unique character string (TxID) assigned to every verified transaction"
flexWrap="nowrap"
isLoading={ isLoading }
>
{ data.status === null && <Spinner mr={ 2 } size="sm" flexShrink={ 0 }/> }
<Skeleton isLoaded={ !isLoading } overflow="hidden">
<HashStringShortenDynamic hash={ data.hash }/>
</Skeleton>
<CopyToClipboard text={ data.hash } isLoading={ isLoading }/>
</DetailsInfoItem>
<DetailsInfoItem
title={ config.features.zkEvmRollup.isEnabled ? 'L2 status and method' : 'Status and method' }
hint="Current transaction state: Success, Failed (Error), or Pending (In Process)"
isLoading={ isLoading }
>
<TxStatus status={ data.status } errorText={ data.status === 'error' ? data.result : undefined } isLoading={ isLoading }/>
{ data.method && (
<Tag colorScheme={ data.method === 'Multicall' ? 'teal' : 'gray' } isLoading={ isLoading } isTruncated ml={ 3 }>
{ data.method }
</Tag>
) }
</DetailsInfoItem>
{ config.features.optimisticRollup.isEnabled && data.op_withdrawals && data.op_withdrawals.length > 0 && (
<DetailsInfoItem
title="Withdrawal status"
hint="Detailed status progress of the transaction"
>
<Flex flexDir="column" rowGap={ 2 }>
{ data.op_withdrawals.map((withdrawal) => (
<Box key={ withdrawal.nonce }>
<Box mb={ 2 }>
<span>Nonce: </span>
<chakra.span fontWeight={ 600 }>{ withdrawal.nonce }</chakra.span>
</Box>
<TxDetailsWithdrawalStatus
status={ withdrawal.status }
l1TxHash={ withdrawal.l1_transaction_hash }
/>
</Box>
)) }
</Flex>
</DetailsInfoItem>
) }
{ data.zkevm_status && (
<DetailsInfoItem
title="Confirmation status"
hint="Status of the transaction confirmation path to L1"
isLoading={ isLoading }
>
<VerificationSteps currentStep={ data.zkevm_status } steps={ ZKEVM_L2_TX_STATUSES } isLoading={ isLoading }/>
</DetailsInfoItem>
) }
{ data.revert_reason && (
<DetailsInfoItem
title="Revert reason"
hint="The revert reason of the transaction"
>
<TxRevertReason { ...data.revert_reason }/>
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Block"
hint="Block number containing the transaction"
isLoading={ isLoading }
>
{ data.block === null ?
<Text>Pending</Text> : (
<BlockEntity
isLoading={ isLoading }
number={ data.block }
noIcon
/>
) }
{ Boolean(data.confirmations) && (
<>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ data.confirmations } Block confirmations</span>
</Skeleton>
</>
) }
</DetailsInfoItem>
{ data.zkevm_batch_number && (
<DetailsInfoItem
title="Tx batch"
hint="Batch index for this transaction"
isLoading={ isLoading }
>
<ZkEvmBatchEntityL2
isLoading={ isLoading }
number={ data.zkevm_batch_number }
/>
</DetailsInfoItem>
) }
{ data.timestamp && (
<DetailsInfoItem
title="Timestamp"
hint="Date & time of transaction inclusion, including length of time for confirmation"
isLoading={ isLoading }
>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isLoading }/>
{ data.confirmation_duration && (
<>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ getConfirmationDuration(data.confirmation_duration) }</span>
</Skeleton>
</>
) }
</DetailsInfoItem>
) }
{ data.execution_node && (
<DetailsInfoItem
title="Kettle"
hint="Node that carried out the confidential computation"
isLoading={ isLoading }
>
<AddressEntity
address={ data.execution_node }
href={ route({ pathname: '/txs/kettle/[hash]', query: { hash: data.execution_node.hash } }) }
/>
</DetailsInfoItem>
) }
{ data.allowed_peekers && data.allowed_peekers.length > 0 && (
<TxAllowedPeekers items={ data.allowed_peekers }/>
) }
<DetailsSponsoredItem isLoading={ isLoading }/>
<DetailsInfoItemDivider/>
<TxDetailsActions hash={ data.hash } actions={ data.actions } isTxDataLoading={ isLoading }/>
<DetailsInfoItem
title="From"
hint="Address (external or contract) sending the transaction"
isLoading={ isLoading }
columnGap={ 3 }
>
<AddressEntity
address={ data.from }
isLoading={ isLoading }
/>
{ data.from.name && <Text>{ data.from.name }</Text> }
{ addressFromTags.length > 0 && (
<Flex columnGap={ 3 }>
{ addressFromTags }
</Flex>
) }
</DetailsInfoItem>
<DetailsInfoItem
title={ data.to?.is_contract ? 'Interacted with contract' : 'To' }
hint="Address (external or contract) receiving the transaction"
isLoading={ isLoading }
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
columnGap={ 3 }
>
{ toAddress ? (
<>
{ data.to && data.to.hash ? (
<Flex flexWrap="nowrap" alignItems="center" maxW="100%">
<AddressEntity
address={ toAddress }
isLoading={ isLoading }
/>
{ executionSuccessBadge }
{ executionFailedBadge }
</Flex>
) : (
<Flex width="100%" whiteSpace="pre" alignItems="center" flexShrink={ 0 }>
<span>[Contract </span>
<AddressEntity
address={ toAddress }
isLoading={ isLoading }
noIcon
/>
<span>created]</span>
{ executionSuccessBadge }
{ executionFailedBadge }
</Flex>
) }
{ addressToTags.length > 0 && (
<Flex columnGap={ 3 }>
{ addressToTags }
</Flex>
) }
</>
) : (
<span>[ Contract creation ]</span>
) }
</DetailsInfoItem>
{ data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash } isOverflow={ data.token_transfers_overflow }/> }
<DetailsInfoItemDivider/>
{ data.zkevm_sequence_hash && (
<DetailsInfoItem
title="Sequence tx hash"
flexWrap="nowrap"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden">
<HashStringShortenDynamic hash={ data.zkevm_sequence_hash }/>
</Skeleton>
<CopyToClipboard text={ data.zkevm_sequence_hash } isLoading={ isLoading }/>
</DetailsInfoItem>
) }
{ data.zkevm_verify_hash && (
<DetailsInfoItem
title="Verify tx hash"
flexWrap="nowrap"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden">
<HashStringShortenDynamic hash={ data.zkevm_verify_hash }/>
</Skeleton>
<CopyToClipboard text={ data.zkevm_verify_hash } isLoading={ isLoading }/>
</DetailsInfoItem>
) }
{ (data.zkevm_batch_number || data.zkevm_verify_hash) && <DetailsInfoItemDivider/> }
{ !config.UI.views.tx.hiddenFields?.value && (
<DetailsInfoItem
title="Value"
hint="Value sent in the native token (and USD) if applicable"
isLoading={ isLoading }
>
<CurrencyValue
value={ data.value }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
isLoading={ isLoading }
flexWrap="wrap"
/>
</DetailsInfoItem>
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<DetailsInfoItem
title="Transaction fee"
hint="Total transaction fee"
isLoading={ isLoading }
>
{ data.stability_fee ? (
<TxFeeStability data={ data.stability_fee } isLoading={ isLoading }/>
) : (
<CurrencyValue
value={ data.fee.value }
currency={ config.UI.views.tx.hiddenFields?.fee_currency ? '' : currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
isLoading={ isLoading }
/>
) }
</DetailsInfoItem>
) }
<TxDetailsGasPrice gasPrice={ data.gas_price } isLoading={ isLoading }/>
<TxDetailsFeePerGas txFee={ data.fee.value } gasUsed={ data.gas_used } isLoading={ isLoading }/>
<DetailsInfoItem
title="Gas usage & limit by txn"
hint="Actual gas amount used by the transaction"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading }>{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isLoading }>{ BigNumber(data.gas_limit).toFormat() }</Skeleton>
<Utilization ml={ 4 } value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() } isLoading={ isLoading }/>
</DetailsInfoItem>
{ !config.UI.views.tx.hiddenFields?.gas_fees &&
(data.base_fee_per_gas || data.max_fee_per_gas || data.max_priority_fee_per_gas) && (
<DetailsInfoItem
title={ `Gas fees (${ currencyUnits.gwei })` }
// eslint-disable-next-line max-len
hint={ `
Base Fee refers to the network Base Fee at the time of the block,
while Max Fee & Max Priority Fee refer to the max amount a user is willing to pay
for their tx & to give to the ${ getNetworkValidatorTitle() } respectively
` }
isLoading={ isLoading }
>
{ data.base_fee_per_gas && (
<Skeleton isLoaded={ !isLoading }>
<Text as="span" fontWeight="500">Base: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.base_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
{ (data.max_fee_per_gas || data.max_priority_fee_per_gas) && <TextSeparator/> }
</Skeleton>
) }
{ data.max_fee_per_gas && (
<Skeleton isLoaded={ !isLoading }>
<Text as="span" fontWeight="500">Max: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
{ data.max_priority_fee_per_gas && <TextSeparator/> }
</Skeleton>
) }
{ data.max_priority_fee_per_gas && (
<Skeleton isLoaded={ !isLoading }>
<Text as="span" fontWeight="500">Max priority: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
</Skeleton>
) }
</DetailsInfoItem>
) }
{ data.tx_burnt_fee && !config.UI.views.tx.hiddenFields?.burnt_fees && !config.features.optimisticRollup.isEnabled && (
<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>
) }
{ config.features.optimisticRollup.isEnabled && (
<>
{ data.l1_gas_used && (
<DetailsInfoItem
title="L1 gas used by txn"
hint="L1 gas used by transaction"
isLoading={ isLoading }
>
<Text>{ BigNumber(data.l1_gas_used).toFormat() }</Text>
</DetailsInfoItem>
) }
{ data.l1_gas_price && (
<DetailsInfoItem
title="L1 gas price"
hint="L1 gas price"
isLoading={ isLoading }
>
<Text mr={ 1 }>{ BigNumber(data.l1_gas_price).dividedBy(WEI).toFixed() } { currencyUnits.ether }</Text>
<Text variant="secondary">({ BigNumber(data.l1_gas_price).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })</Text>
</DetailsInfoItem>
) }
{ data.l1_fee && (
<DetailsInfoItem
title="L1 fee"
// eslint-disable-next-line max-len
hint={ `L1 Data Fee which is used to cover the L1 "security" cost from the batch submission mechanism. In combination with L2 execution fee, L1 fee makes the total amount of fees that a transaction pays.` }
isLoading={ isLoading }
>
<CurrencyValue
value={ data.l1_fee }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
/>
</DetailsInfoItem>
) }
{ data.l1_fee_scalar && (
<DetailsInfoItem
title="L1 fee scalar"
hint="A Dynamic overhead (fee scalar) premium, which serves as a buffer in case L1 prices rapidly increase."
isLoading={ isLoading }
>
<Text>{ data.l1_fee_scalar }</Text>
</DetailsInfoItem>
) }
</>
) }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Element name="TxInfo__cutLink">
<Skeleton isLoaded={ !isLoading } mt={ 6 } display="inline-block">
<Link
display="inline-block"
fontSize="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
onClick={ handleCutClick }
>
{ isExpanded ? 'Hide details' : 'View details' }
</Link>
</Skeleton>
</Element>
</GridItem>
{ isExpanded && (
<>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
<TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position }/>
<DetailsInfoItem
title="Raw input"
hint="Binary data included with the transaction. See logs tab for additional info"
>
<RawInputData hex={ data.raw_input }/>
</DetailsInfoItem>
{ data.decoded_input && (
<DetailsInfoItem
title="Decoded input data"
hint="Decoded input data"
>
<LogDecodedInputData data={ data.decoded_input }/>
</DetailsInfoItem>
) }
</>
) }
</Grid>
);
};
export default TxInfo;
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app'; import config from 'configs/app';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile'; import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
...@@ -31,7 +32,7 @@ const TxInternalsListItem = ({ type, from, to, value, success, error, gas_limit: ...@@ -31,7 +32,7 @@ const TxInternalsListItem = ({ type, from, to, value, success, error, gas_limit:
fontWeight="500" fontWeight="500"
/> />
<HStack spacing={ 3 }> <HStack spacing={ 3 }>
<Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Value { config.chain.currency.symbol }</Skeleton> <Skeleton isLoaded={ !isLoading } fontSize="sm" fontWeight={ 500 }>Value { currencyUnits.ether }</Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary"> <Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary">
{ BigNumber(value).div(BigNumber(10 ** config.chain.currency.decimals)).toFormat() } { BigNumber(value).div(BigNumber(10 ** config.chain.currency.decimals)).toFormat() }
</Skeleton> </Skeleton>
......
...@@ -3,8 +3,8 @@ import React from 'react'; ...@@ -3,8 +3,8 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction'; import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { currencyUnits } from 'lib/units';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
import TxInternalsTableItem from 'ui/tx/internals/TxInternalsTableItem'; import TxInternalsTableItem from 'ui/tx/internals/TxInternalsTableItem';
...@@ -31,13 +31,13 @@ const TxInternalsTable = ({ data, sort, onSortToggle, top, isLoading }: Props) = ...@@ -31,13 +31,13 @@ const TxInternalsTable = ({ data, sort, onSortToggle, top, isLoading }: Props) =
<Th width="16%" isNumeric> <Th width="16%" isNumeric>
<Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ onSortToggle('value') } columnGap={ 1 }> <Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ onSortToggle('value') } columnGap={ 1 }>
{ sort?.includes('value') && <IconSvg name="arrows/east" boxSize={ 4 } transform={ sortIconTransform }/> } { sort?.includes('value') && <IconSvg name="arrows/east" boxSize={ 4 } transform={ sortIconTransform }/> }
Value { config.chain.currency.symbol } Value { currencyUnits.ether }
</Link> </Link>
</Th> </Th>
<Th width="16%" isNumeric> <Th width="16%" isNumeric>
<Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ onSortToggle('gas-limit') } columnGap={ 1 }> <Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ onSortToggle('gas-limit') } columnGap={ 1 }>
{ sort?.includes('gas-limit') && <IconSvg name="arrows/east" boxSize={ 4 } transform={ sortIconTransform }/> } { sort?.includes('gas-limit') && <IconSvg name="arrows/east" boxSize={ 4 } transform={ sortIconTransform }/> }
Gas limit { config.chain.currency.symbol } Gas limit { currencyUnits.ether }
</Link> </Link>
</Th> </Th>
</Tr> </Tr>
......
...@@ -4,9 +4,9 @@ import React from 'react'; ...@@ -4,9 +4,9 @@ import React from 'react';
import type { TxInterpretationSummary, TxInterpretationVariable } from 'types/api/txInterpretation'; import type { TxInterpretationSummary, TxInterpretationVariable } from 'types/api/txInterpretation';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
...@@ -102,7 +102,7 @@ const TxInterpretation = ({ summary, isLoading, className }: Props) => { ...@@ -102,7 +102,7 @@ const TxInterpretation = ({ summary, isLoading, className }: Props) => {
<Text color="text_secondary">{ chunk.trim() + (chunk.trim() && variablesNames[index] ? ' ' : '') }</Text> <Text color="text_secondary">{ chunk.trim() + (chunk.trim() && variablesNames[index] ? ' ' : '') }</Text>
{ index < variablesNames.length && ( { index < variablesNames.length && (
variablesNames[index] === NATIVE_COIN_SYMBOL_VAR_NAME ? variablesNames[index] === NATIVE_COIN_SYMBOL_VAR_NAME ?
<Text>{ config.chain.currency.symbol + ' ' }</Text> : <Text>{ currencyUnits.ether + ' ' }</Text> :
<TxInterpretationElementByType variable={ variables[variablesNames[index]] }/> <TxInterpretationElementByType variable={ variables[variablesNames[index]] }/>
) } ) }
</Flex> </Flex>
......
...@@ -8,6 +8,7 @@ import config from 'configs/app'; ...@@ -8,6 +8,7 @@ import config from 'configs/app';
import { ZERO_ADDRESS } from 'lib/consts'; import { ZERO_ADDRESS } from 'lib/consts';
import { nbsp, space } from 'lib/html-entities'; import { nbsp, space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag'; import Tag from 'ui/shared/chakra/Tag';
import NftEntity from 'ui/shared/entities/nft/NftEntity'; import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity';
...@@ -59,12 +60,12 @@ export function getStateElements(data: TxStateChange, isLoading?: boolean) { ...@@ -59,12 +60,12 @@ export function getStateElements(data: TxStateChange, isLoading?: boolean) {
return { return {
before: ( before: (
<Skeleton isLoaded={ !isLoading } wordBreak="break-all" display="inline-block"> <Skeleton isLoaded={ !isLoading } wordBreak="break-all" display="inline-block">
{ beforeBn.toFormat() } { config.chain.currency.symbol } { beforeBn.toFormat() } { currencyUnits.ether }
</Skeleton> </Skeleton>
), ),
after: ( after: (
<Skeleton isLoaded={ !isLoading } wordBreak="break-all" display="inline-block"> <Skeleton isLoaded={ !isLoading } wordBreak="break-all" display="inline-block">
{ afterBn.toFormat() } { config.chain.currency.symbol } { afterBn.toFormat() } { currencyUnits.ether }
</Skeleton> </Skeleton>
), ),
change: ( change: (
......
import { useBoolean } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
...@@ -9,44 +10,63 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -9,44 +10,63 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app'; import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery'; import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { retry } from 'lib/api/useQueryClientConfig';
import { SECOND } from 'lib/consts';
import delay from 'lib/delay'; import delay from 'lib/delay';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel'; import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage'; import useSocketMessage from 'lib/socket/useSocketMessage';
import { TX, TX_ZKEVM_L2 } from 'stubs/tx'; import { TX, TX_ZKEVM_L2 } from 'stubs/tx';
interface Params { export type TxQuery = UseQueryResult<Transaction, ResourceError<{ status: number }>> & {
onTxStatusUpdate?: () => void; socketStatus: 'close' | 'error' | undefined;
updateDelay?: number; setRefetchOnError: {
on: () => void;
off: () => void;
toggle: () => void;
};
} }
type ReturnType = UseQueryResult<Transaction, ResourceError<{ status: number }>> & { interface Params {
socketStatus: 'close' | 'error' | undefined; hash?: string;
isEnabled?: boolean;
} }
export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay }: Params | undefined = {}): ReturnType { export default function useTxQuery(params?: Params): TxQuery {
const [ socketStatus, setSocketStatus ] = React.useState<'close' | 'error'>();
const [ isRefetchEnabled, setRefetchEnabled ] = useBoolean(false);
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [ socketStatus, setSocketStatus ] = React.useState<'close' | 'error'>();
const hash = getQueryParamString(router.query.hash); const hash = params?.hash ?? getQueryParamString(router.query.hash);
const queryResult = useApiQuery<'tx', { status: number }>('tx', { const queryResult = useApiQuery<'tx', { status: number }>('tx', {
pathParams: { hash }, pathParams: { hash },
queryOptions: { queryOptions: {
enabled: Boolean(hash), enabled: Boolean(hash) && params?.isEnabled !== false,
refetchOnMount: false, refetchOnMount: false,
placeholderData: config.features.zkEvmRollup.isEnabled ? TX_ZKEVM_L2 : TX, placeholderData: config.features.zkEvmRollup.isEnabled ? TX_ZKEVM_L2 : TX,
retry: (failureCount, error) => {
if (isRefetchEnabled) {
return false;
}
return retry(failureCount, error);
},
refetchInterval: (): number | false => {
return isRefetchEnabled ? 15 * SECOND : false;
},
}, },
}); });
const { data, isError, isPending } = queryResult; const { data, isError, isPlaceholderData, isPending } = queryResult;
const handleStatusUpdateMessage: SocketMessage.TxStatusUpdate['handler'] = React.useCallback(async() => { const handleStatusUpdateMessage: SocketMessage.TxStatusUpdate['handler'] = React.useCallback(async() => {
updateDelay && await delay(updateDelay); await delay(5 * SECOND);
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: getResourceKey('tx', { pathParams: { hash } }), queryKey: getResourceKey('tx', { pathParams: { hash } }),
}); });
onTxStatusUpdate?.(); }, [ queryClient, hash ]);
}, [ onTxStatusUpdate, queryClient, hash, updateDelay ]);
const handleSocketClose = React.useCallback(() => { const handleSocketClose = React.useCallback(() => {
setSocketStatus('close'); setSocketStatus('close');
...@@ -60,7 +80,7 @@ export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay }: Params ...@@ -60,7 +80,7 @@ export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay }: Params
topic: `transactions:${ hash }`, topic: `transactions:${ hash }`,
onSocketClose: handleSocketClose, onSocketClose: handleSocketClose,
onSocketError: handleSocketError, onSocketError: handleSocketError,
isDisabled: isPending || isError || data.status !== null, isDisabled: isPending || isPlaceholderData || isError || data.status !== null,
}); });
useSocketMessage({ useSocketMessage({
channel, channel,
...@@ -68,8 +88,9 @@ export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay }: Params ...@@ -68,8 +88,9 @@ export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay }: Params
handler: handleStatusUpdateMessage, handler: handleStatusUpdateMessage,
}); });
return { return React.useMemo(() => ({
...queryResult, ...queryResult,
socketStatus, socketStatus,
}; setRefetchOnError: setRefetchEnabled,
}), [ queryResult, socketStatus, setRefetchEnabled ]);
} }
...@@ -8,6 +8,7 @@ import { route } from 'nextjs-routes'; ...@@ -8,6 +8,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator'; import TextSeparator from 'ui/shared/TextSeparator';
...@@ -42,7 +43,7 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => { ...@@ -42,7 +43,7 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
<Flex> <Flex>
<CurrencyValue <CurrencyValue
value={ tx.fee.value } value={ tx.fee.value }
currency={ config.UI.views.tx.hiddenFields?.fee_currency ? '' : config.chain.currency.symbol } currency={ config.UI.views.tx.hiddenFields?.fee_currency ? '' : currencyUnits.ether }
exchangeRate={ tx.exchange_rate } exchangeRate={ tx.exchange_rate }
accuracyUsd={ 2 } accuracyUsd={ 2 }
flexWrap="wrap" flexWrap="wrap"
...@@ -68,7 +69,7 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => { ...@@ -68,7 +69,7 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
{ !config.UI.views.tx.hiddenFields?.gas_fees && { !config.UI.views.tx.hiddenFields?.gas_fees &&
(tx.base_fee_per_gas !== null || tx.max_fee_per_gas !== null || tx.max_priority_fee_per_gas !== null) && ( (tx.base_fee_per_gas !== null || tx.max_fee_per_gas !== null || tx.max_priority_fee_per_gas !== null) && (
<Box { ...sectionProps } mb={ 4 }> <Box { ...sectionProps } mb={ 4 }>
<Text { ...sectionTitleProps }>Gas fees (Gwei)</Text> <Text { ...sectionTitleProps }>Gas fees ({ currencyUnits.gwei })</Text>
{ 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>
......
import { Box, Show, Hide } from '@chakra-ui/react'; import { Show, Hide } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { AddressFromToFilter } from 'types/api/address'; import type { AddressFromToFilter } from 'types/api/address';
...@@ -8,11 +8,10 @@ import useIsMobile from 'lib/hooks/useIsMobile'; ...@@ -8,11 +8,10 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink'; import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import DataListDisplay from 'ui/shared/DataListDisplay'; import DataListDisplay from 'ui/shared/DataListDisplay';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import getNextSortValue from 'ui/shared/sort/getNextSortValue'; import getNextSortValue from 'ui/shared/sort/getNextSortValue';
import TxsHeaderMobile from './TxsHeaderMobile'; import TxsHeaderMobile from './TxsHeaderMobile';
import TxsListItem from './TxsListItem'; import TxsList from './TxsList';
import TxsTable from './TxsTable'; import TxsTable from './TxsTable';
const SORT_SEQUENCE: Record<TransactionsSortingField, Array<TransactionsSortingValue | undefined>> = { const SORT_SEQUENCE: Record<TransactionsSortingField, Array<TransactionsSortingValue | undefined>> = {
...@@ -66,26 +65,16 @@ const TxsContent = ({ ...@@ -66,26 +65,16 @@ const TxsContent = ({
const content = items ? ( const content = items ? (
<> <>
<Show below="lg" ssr={ false }> <Show below="lg" ssr={ false }>
<Box> <TxsList
{ showSocketInfo && ( showBlockInfo={ showBlockInfo }
<SocketNewItemsNotice.Mobile showSocketInfo={ showSocketInfo }
url={ window.location.href } socketInfoAlert={ socketInfoAlert }
num={ socketInfoNum } socketInfoNum={ socketInfoNum }
alert={ socketInfoAlert } isLoading={ isPlaceholderData }
isLoading={ isPlaceholderData } enableTimeIncrement={ enableTimeIncrement }
/> currentAddress={ currentAddress }
) } items={ items }
{ items.map((tx, index) => ( />
<TxsListItem
key={ tx.hash + (isPlaceholderData ? index : '') }
tx={ tx }
showBlockInfo={ showBlockInfo }
currentAddress={ currentAddress }
enableTimeIncrement={ enableTimeIncrement }
isLoading={ isPlaceholderData }
/>
)) }
</Box>
</Show> </Show>
<Hide below="lg" ssr={ false }> <Hide below="lg" ssr={ false }>
<TxsTable <TxsTable
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { Transaction } from 'types/api/transaction';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TxsListItem from './TxsListItem';
interface Props {
showBlockInfo: boolean;
showSocketInfo?: boolean;
socketInfoAlert?: string;
socketInfoNum?: number;
enableTimeIncrement?: boolean;
currentAddress?: string;
isLoading: boolean;
items: Array<Transaction>;
}
const TxsList = (props: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(props.items, !props.isLoading);
return (
<Box>
{ props.showSocketInfo && (
<SocketNewItemsNotice.Mobile
url={ window.location.href }
num={ props.socketInfoNum }
alert={ props.socketInfoAlert }
isLoading={ props.isLoading }
/>
) }
{ props.items.slice(0, renderedItemsNum).map((tx, index) => (
<TxsListItem
key={ tx.hash + (props.isLoading ? index : '') }
tx={ tx }
showBlockInfo={ props.showBlockInfo }
currentAddress={ props.currentAddress }
enableTimeIncrement={ props.enableTimeIncrement }
isLoading={ props.isLoading }
/>
)) }
<Box ref={ cutRef } h={ 0 }/>
</Box>
);
};
export default React.memo(TxsList);
...@@ -11,6 +11,7 @@ import config from 'configs/app'; ...@@ -11,6 +11,7 @@ import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement'; import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { space } from 'lib/html-entities'; import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo'; import AddressFromTo from 'ui/shared/address/AddressFromTo';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
...@@ -95,7 +96,7 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI ...@@ -95,7 +96,7 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI
<Skeleton isLoaded={ !isLoading } display="inline-block" variant="text_secondary" whiteSpace="pre"> <Skeleton isLoaded={ !isLoading } display="inline-block" variant="text_secondary" whiteSpace="pre">
{ getValueWithUnit(tx.value).toFormat() } { getValueWithUnit(tx.value).toFormat() }
{ space } { space }
{ config.chain.currency.symbol } { currencyUnits.ether }
</Skeleton> </Skeleton>
</Flex> </Flex>
) } ) }
...@@ -109,7 +110,7 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI ...@@ -109,7 +110,7 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI
) : ( ) : (
<Skeleton isLoaded={ !isLoading } display="inline-block" variant="text_secondary" whiteSpace="pre"> <Skeleton isLoaded={ !isLoading } display="inline-block" variant="text_secondary" whiteSpace="pre">
{ getValueWithUnit(tx.fee.value || 0).toFormat() } { getValueWithUnit(tx.fee.value || 0).toFormat() }
{ config.UI.views.tx.hiddenFields?.fee_currency ? '' : ` ${ config.chain.currency.symbol }` } { config.UI.views.tx.hiddenFields?.fee_currency ? '' : ` ${ currencyUnits.ether }` }
</Skeleton> </Skeleton>
) } ) }
</> </>
......
...@@ -6,6 +6,8 @@ import type { Transaction, TransactionsSortingField, TransactionsSortingValue } ...@@ -6,6 +6,8 @@ import type { Transaction, TransactionsSortingField, TransactionsSortingValue }
import config from 'configs/app'; import config from 'configs/app';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight'; import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import { currencyUnits } from 'lib/units';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice'; import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TheadSticky from 'ui/shared/TheadSticky'; import TheadSticky from 'ui/shared/TheadSticky';
...@@ -39,6 +41,8 @@ const TxsTable = ({ ...@@ -39,6 +41,8 @@ const TxsTable = ({
enableTimeIncrement, enableTimeIncrement,
isLoading, isLoading,
}: Props) => { }: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(txs, !isLoading);
return ( return (
<AddressHighlightProvider> <AddressHighlightProvider>
<Table variant="simple" minWidth="950px" size="xs"> <Table variant="simple" minWidth="950px" size="xs">
...@@ -55,7 +59,7 @@ const TxsTable = ({ ...@@ -55,7 +59,7 @@ const TxsTable = ({
<Link onClick={ sort('value') } display="flex" justifyContent="end"> <Link onClick={ sort('value') } display="flex" justifyContent="end">
{ sorting === 'value-asc' && <IconSvg boxSize={ 5 } name="arrows/east" transform="rotate(-90deg)"/> } { sorting === 'value-asc' && <IconSvg boxSize={ 5 } name="arrows/east" transform="rotate(-90deg)"/> }
{ sorting === 'value-desc' && <IconSvg boxSize={ 5 } name="arrows/east" transform="rotate(90deg)"/> } { sorting === 'value-desc' && <IconSvg boxSize={ 5 } name="arrows/east" transform="rotate(90deg)"/> }
{ `Value ${ config.chain.currency.symbol }` } { `Value ${ currencyUnits.ether }` }
</Link> </Link>
</Th> </Th>
) } ) }
...@@ -64,7 +68,7 @@ const TxsTable = ({ ...@@ -64,7 +68,7 @@ const TxsTable = ({
<Link onClick={ sort('fee') } display="flex" justifyContent="end"> <Link onClick={ sort('fee') } display="flex" justifyContent="end">
{ sorting === 'fee-asc' && <IconSvg boxSize={ 5 } name="arrows/east" transform="rotate(-90deg)"/> } { sorting === 'fee-asc' && <IconSvg boxSize={ 5 } name="arrows/east" transform="rotate(-90deg)"/> }
{ sorting === 'fee-desc' && <IconSvg boxSize={ 5 } name="arrows/east" transform="rotate(90deg)"/> } { sorting === 'fee-desc' && <IconSvg boxSize={ 5 } name="arrows/east" transform="rotate(90deg)"/> }
{ `Fee${ config.UI.views.tx.hiddenFields?.fee_currency ? '' : ` ${ config.chain.currency.symbol }` }` } { `Fee${ config.UI.views.tx.hiddenFields?.fee_currency ? '' : ` ${ currencyUnits.ether }` }` }
</Link> </Link>
</Th> </Th>
) } ) }
...@@ -80,7 +84,7 @@ const TxsTable = ({ ...@@ -80,7 +84,7 @@ const TxsTable = ({
/> />
) } ) }
<AnimatePresence initial={ false }> <AnimatePresence initial={ false }>
{ txs.map((item, index) => ( { txs.slice(0, renderedItemsNum).map((item, index) => (
<TxsTableItem <TxsTableItem
key={ item.hash + (isLoading ? index : '') } key={ item.hash + (isLoading ? index : '') }
tx={ item } tx={ item }
...@@ -93,6 +97,7 @@ const TxsTable = ({ ...@@ -93,6 +97,7 @@ const TxsTable = ({
</AnimatePresence> </AnimatePresence>
</Tbody> </Tbody>
</Table> </Table>
<div ref={ cutRef }/>
</AddressHighlightProvider> </AddressHighlightProvider>
); );
}; };
......
import { Grid, GridItem, Text, Link, Skeleton } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import BigNumber from 'bignumber.js';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import type { UserOp } from 'types/api/userOps';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import RawInputData from 'ui/shared/RawInputData';
import UserOpsAddress from 'ui/shared/userOps/UserOpsAddress';
import UserOpSponsorType from 'ui/shared/userOps/UserOpSponsorType';
import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
query: UseQueryResult<UserOp, ResourceError>;
}
const CUT_LINK_NAME = 'UserOpDetails__cutLink';
const UserOpDetails = ({ query }: Props) => {
const [ isExpanded, setIsExpanded ] = React.useState(false);
const { data, isPlaceholderData, isError, error } = query;
const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag);
scroller.scrollTo(CUT_LINK_NAME, {
duration: 500,
smooth: true,
});
}, []);
if (isError) {
if (error?.status === 400 || error?.status === 404 || error?.status === 422) {
throwOnResourceLoadError({ isError, error });
}
return <DataFetchAlert/>;
}
if (!data) {
return null;
}
return (
<Grid
columnGap={ 8 }
rowGap={{ base: 3, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(min-content, 220px) minmax(0, 1fr)' }}
overflow="hidden"
>
<DetailsInfoItem
title="User operation hash"
hint="Unique character string assigned to every User operation"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData } overflow="hidden">
<UserOpEntity hash={ data.hash } noIcon noLink noCopy={ false }/>
</Skeleton>
</DetailsInfoItem>
<DetailsInfoItem
title="Sender"
hint="The address of the smart contract account"
isLoading={ isPlaceholderData }
>
<UserOpsAddress address={ data.sender } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Status"
hint="Current User operation state"
isLoading={ isPlaceholderData }
>
<UserOpStatus status={ data.status } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
{ data.revert_reason && (
<DetailsInfoItem
title="Revert reason"
hint="The revert reason of the User operation"
isLoading={ isPlaceholderData }
wordBreak="break-all"
whiteSpace="normal"
>
<Skeleton isLoaded={ !isPlaceholderData }>
{ data.revert_reason }
</Skeleton>
</DetailsInfoItem>
) }
{ data.timestamp && (
<DetailsInfoItem
title="Timestamp"
hint="Date and time of User operation"
isLoading={ isPlaceholderData }
>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<DetailsInfoItem
title="Fee"
hint="Total User operation fee"
isLoading={ isPlaceholderData }
>
<CurrencyValue
value={ data.fee }
currency={ currencyUnits.ether }
isLoading={ isPlaceholderData }
/>
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Gas limit"
hint="Gas limit for the User operation"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.gas).toFormat() }
</Skeleton>
</DetailsInfoItem>
<DetailsInfoItem
title="Gas used"
hint="Actual gas amount used by the the User operation"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.gas_used).toFormat() }
</Skeleton>
<Utilization
ml={ 4 }
colorScheme="gray"
value={ BigNumber(data.gas_used).dividedBy(BigNumber(data.gas)).toNumber() }
isLoading={ isPlaceholderData }
/>
</DetailsInfoItem>
<DetailsInfoItem
title="Transaction hash"
hint="Hash of the transaction this User operation belongs to"
isLoading={ isPlaceholderData }
>
<TxEntity hash={ data.transaction_hash } isLoading={ isPlaceholderData } noCopy={ false }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Block"
hint="Block number containing this User operation"
isLoading={ isPlaceholderData }
>
<BlockEntity number={ data.block_number } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Entry point"
hint="Contract that executes bundles of User operations"
isLoading={ isPlaceholderData }
>
<UserOpsAddress address={ data.entry_point } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
{ /* CUT */ }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Element name={ CUT_LINK_NAME }>
<Skeleton isLoaded={ !isPlaceholderData } mt={ 6 } display="inline-block">
<Link
fontSize="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
onClick={ handleCutClick }
>
{ isExpanded ? 'Hide details' : 'View details' }
</Link>
</Skeleton>
</Element>
</GridItem>
{ /* ADDITIONAL INFO */ }
{ isExpanded && !isPlaceholderData && (
<>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
<DetailsInfoItem
title="Call gas limit"
hint="Gas limit for execution phase"
>
{ BigNumber(data.call_gas_limit).toFormat() }
</DetailsInfoItem>
<DetailsInfoItem
title="Verification gas limit"
hint="Gas limit for verification phase"
>
{ BigNumber(data.verification_gas_limit).toFormat() }
</DetailsInfoItem>
<DetailsInfoItem
title="Pre-verification gas"
hint="Gas to compensate the bundler"
>
{ BigNumber(data.pre_verification_gas).toFormat() }
</DetailsInfoItem>
{ !config.UI.views.tx.hiddenFields?.gas_fees && (
<>
<DetailsInfoItem
title="Max fee per gas"
hint="Maximum fee per gas "
>
<Text>{ BigNumber(data.max_fee_per_gas).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text>
</DetailsInfoItem><DetailsInfoItem
title="Max priority fee per gas"
hint="Maximum priority fee per gas"
>
<Text>{ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text>
</DetailsInfoItem>
</>
) }
<DetailsInfoItemDivider/>
{ data.aggregator && (
<DetailsInfoItem
title="Aggregator"
hint="Helper contract to validate an aggregated signature"
>
<UserOpsAddress address={ data.aggregator }/>
</DetailsInfoItem>
) }
{ data.aggregator_signature && (
<DetailsInfoItem
title="Aggregator signature"
hint="Aggregator signature"
>
{ data.aggregator_signature }
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Bundler"
hint="A node (block builder) that handles User operations"
>
<UserOpsAddress address={ data.bundler }/>
</DetailsInfoItem>
{ data.factory && (
<DetailsInfoItem
title="Factory"
hint="Smart contract that deploys new smart contract wallets for users"
>
<UserOpsAddress address={ data.factory }/>
</DetailsInfoItem>
) }
{ data.paymaster && (
<DetailsInfoItem
title="Paymaster"
hint="Contract to sponsor the gas fees for User operations"
>
<UserOpsAddress address={ data.paymaster }/>
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Sponsor type"
hint="Type of the gas fees sponsor"
>
<UserOpSponsorType sponsorType={ data.sponsor_type }/>
</DetailsInfoItem>
<DetailsInfoItemDivider/>
<DetailsInfoItem
title="Signature"
hint="Used to validate a User operation along with the nonce during verification"
wordBreak="break-all"
whiteSpace="normal"
>
{ data.signature }
</DetailsInfoItem>
<DetailsInfoItem
title="Nonce"
hint="Anti-replay protection; also used as the salt for first-time account creation"
wordBreak="break-all"
whiteSpace="normal"
>
{ data.nonce }
</DetailsInfoItem>
<DetailsInfoItem
title="Call data"
hint="Data that’s passed to the sender for execution"
>
<RawInputData hex={ data.call_data }/>
</DetailsInfoItem>
</>
) }
</Grid>
);
};
export default UserOpDetails;
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { UserOp } from 'types/api/userOps';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
// order is taken from the ERC-4337 standard
// eslint-disable-next-line max-len
const KEYS_ORDER: Array<keyof UserOp['raw']> = [ 'sender', 'nonce', 'init_code', 'call_data', 'call_gas_limit', 'verification_gas_limit', 'pre_verification_gas', 'max_fee_per_gas', 'max_priority_fee_per_gas', 'paymaster_and_data', 'signature' ];
interface Props {
rawData?: UserOp['raw'];
isLoading?: boolean;
}
const UserOpRaw = ({ rawData, isLoading }: Props) => {
if (!rawData) {
return null;
}
const text = JSON.stringify(KEYS_ORDER.reduce((res: UserOp['raw'], key: keyof UserOp['raw']) => {
res[key] = rawData[key];
return res;
}, {} as UserOp['raw']), undefined, 4);
return <Skeleton isLoaded={ !isLoading }><RawDataSnippet data={ text }/></Skeleton>;
};
export default UserOpRaw;
import { Hide, Show } from '@chakra-ui/react';
import React from 'react';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import UserOpsListItem from 'ui/userOps/UserOpsListItem';
import UserOpsTable from 'ui/userOps/UserOpsTable';
type Props = {
query: QueryWithPagesResult<'user_ops'>;
showTx?: boolean;
showSender?: boolean;
};
const UserOpsContent = ({ query, showTx = true, showSender = true }: Props) => {
if (query.isError) {
return <DataFetchAlert/>;
}
const content = query.data?.items ? (
<>
<Hide below="lg" ssr={ false }>
<UserOpsTable
items={ query.data.items }
top={ query.pagination.isVisible ? 0 : 80 }
isLoading={ query.isPlaceholderData }
showTx={ showTx }
showSender={ showSender }
/>
</Hide>
<Show below="lg" ssr={ false }>
{ query.data.items.map((item, index) => (
<UserOpsListItem
key={ item.hash + (query.isPlaceholderData ? String(index) : '') }
item={ item }
isLoading={ query.isPlaceholderData }
showTx={ showTx }
showSender={ showSender }
/>
)) }
</Show>
</>
) : null;
const actionBar = query.pagination.isVisible ? (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...query.pagination }/>
</ActionBar>
) : null;
return (
<DataListDisplay
isError={ query.isError }
items={ query.data?.items }
emptyText="There are no user operations."
content={ content }
actionBar={ actionBar }
/>
);
};
export default UserOpsContent;
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { UserOpsItem } from 'types/api/userOps';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CurrencyValue from 'ui/shared/CurrencyValue';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import UserOpsAddress from 'ui/shared/userOps/UserOpsAddress';
import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
type Props = {
item: UserOpsItem;
isLoading?: boolean;
showTx: boolean;
showSender: boolean;
};
const UserOpsListItem = ({ item, isLoading, showTx, showSender }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
return (
<ListItemMobileGrid.Container gridTemplateColumns="100px auto">
<ListItemMobileGrid.Label isLoading={ isLoading }>User op hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<UserOpEntity hash={ item.hash } isLoading={ isLoading } fontWeight="700" noIcon/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<UserOpStatus status={ item.status } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
{ showSender && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Sender</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<UserOpsAddress
address={ item.address }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
</>
) }
{ showTx && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Tx hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TxEntity
hash={ item.transaction_hash }
isLoading={ isLoading }
noIcon
/>
</ListItemMobileGrid.Value>
</>
) }
<ListItemMobileGrid.Label isLoading={ isLoading }>Block</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlockEntity
number={ item.block_number }
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
noIcon
/>
</ListItemMobileGrid.Value>
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Fee</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
<CurrencyValue value={ item.fee } isLoading={ isLoading } accuracy={ 8 } currency={ config.chain.currency.symbol }/>
</Skeleton>
</ListItemMobileGrid.Value>
</>
) }
</ListItemMobileGrid.Container>
);
};
export default UserOpsListItem;
import { Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { UserOpsItem } from 'types/api/userOps';
import config from 'configs/app';
import { default as Thead } from 'ui/shared/TheadSticky';
import UserOpsTableItem from './UserOpsTableItem';
type Props = {
items: Array<UserOpsItem>;
isLoading?: boolean;
top: number;
showTx: boolean;
showSender: boolean;
};
const UserOpsTable = ({ items, isLoading, top, showTx, showSender }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
<Th w="60%">User op hash</Th>
<Th w="110px">Age</Th>
<Th w="140px">Status</Th>
{ showSender && <Th w="160px">Sender</Th> }
{ showTx && <Th w="160px">Tx hash</Th> }
<Th w="40%">Block</Th>
{ !config.UI.views.tx.hiddenFields?.tx_fee && <Th w="120px" isNumeric>{ `Fee ${ config.chain.currency.symbol }` }</Th> }
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => {
return (
<UserOpsTableItem
key={ item.hash + (isLoading ? String(index) : '') }
item={ item }
isLoading={ isLoading }
showSender={ showSender }
showTx={ showTx }
/>
);
}) }
</Tbody>
</Table>
);
};
export default UserOpsTable;
import { Td, Tr, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { UserOpsItem } from 'types/api/userOps';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import CurrencyValue from 'ui/shared/CurrencyValue';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import UserOpsAddress from 'ui/shared/userOps/UserOpsAddress';
import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
type Props = {
item: UserOpsItem;
isLoading?: boolean;
showTx: boolean;
showSender: boolean;
};
const UserOpsTableItem = ({ item, isLoading, showTx, showSender }: Props) => {
const timeAgo = dayjs(item.timestamp).fromNow();
return (
<Tr>
<Td verticalAlign="middle">
<UserOpEntity hash={ item.hash } isLoading={ isLoading } noIcon fontWeight={ 700 }/>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } color="text_secondary" display="inline-block"><span>{ timeAgo }</span></Skeleton>
</Td>
<Td verticalAlign="middle">
<UserOpStatus status={ item.status } isLoading={ isLoading }/>
</Td>
{ showSender && (
<Td verticalAlign="middle">
<UserOpsAddress
address={ item.address }
isLoading={ isLoading }
truncation="constant"
/>
</Td>
) }
{ showTx && (
<Td verticalAlign="middle">
<TxEntity
hash={ item.transaction_hash }
isLoading={ isLoading }
truncation="constant"
noIcon
/>
</Td>
) }
<Td verticalAlign="middle">
<BlockEntity
number={ item.block_number }
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
noIcon
/>
</Td>
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<Td verticalAlign="middle" isNumeric>
<CurrencyValue value={ item.fee } isLoading={ isLoading } accuracy={ 8 }/>
</Td>
) }
</Tr>
);
};
export default UserOpsTableItem;
...@@ -6,6 +6,7 @@ import type { VerifiedContract } from 'types/api/contracts'; ...@@ -6,6 +6,7 @@ import type { VerifiedContract } from 'types/api/contracts';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
...@@ -37,7 +38,7 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => { ...@@ -37,7 +38,7 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => {
<CopyToClipboard text={ data.address.hash } isLoading={ isLoading }/> <CopyToClipboard text={ data.address.hash } isLoading={ isLoading }/>
</Flex> </Flex>
<Flex columnGap={ 3 }> <Flex columnGap={ 3 }>
<Skeleton isLoaded={ !isLoading } fontWeight={ 500 }>Balance { config.chain.currency.symbol }</Skeleton> <Skeleton isLoaded={ !isLoading } fontWeight={ 500 }>Balance { currencyUnits.ether }</Skeleton>
<Skeleton isLoaded={ !isLoading } color="text_secondary"> <Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ balance }</span> <span>{ balance }</span>
</Skeleton> </Skeleton>
......
...@@ -4,7 +4,7 @@ import React from 'react'; ...@@ -4,7 +4,7 @@ import React from 'react';
import type { VerifiedContract } from 'types/api/contracts'; import type { VerifiedContract } from 'types/api/contracts';
import type { VerifiedContractsSorting, VerifiedContractsSortingField, VerifiedContractsSortingValue } from 'types/api/verifiedContracts'; import type { VerifiedContractsSorting, VerifiedContractsSortingField, VerifiedContractsSortingValue } from 'types/api/verifiedContracts';
import config from 'configs/app'; import { currencyUnits } from 'lib/units';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import getNextSortValue from 'ui/shared/sort/getNextSortValue'; import getNextSortValue from 'ui/shared/sort/getNextSortValue';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
...@@ -35,7 +35,7 @@ const VerifiedContractsTable = ({ data, sort, setSorting, isLoading }: Props) => ...@@ -35,7 +35,7 @@ const VerifiedContractsTable = ({ data, sort, setSorting, isLoading }: Props) =>
<Th width="130px" isNumeric> <Th width="130px" isNumeric>
<Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ isLoading ? undefined : onSortToggle('balance') } columnGap={ 1 }> <Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ isLoading ? undefined : onSortToggle('balance') } columnGap={ 1 }>
{ sort?.includes('balance') && <IconSvg name="arrows/east" boxSize={ 4 } transform={ sortIconTransform }/> } { sort?.includes('balance') && <IconSvg name="arrows/east" boxSize={ 4 } transform={ sortIconTransform }/> }
Balance { config.chain.currency.symbol } Balance { currencyUnits.ether }
</Link> </Link>
</Th> </Th>
<Th width="130px" isNumeric> <Th width="130px" isNumeric>
......
...@@ -7,6 +7,7 @@ import type { WatchlistAddress } from 'types/api/account'; ...@@ -7,6 +7,7 @@ import type { WatchlistAddress } from 'types/api/account';
import config from 'configs/app'; import config from 'configs/app';
import getCurrencyValue from 'lib/getCurrencyValue'; import getCurrencyValue from 'lib/getCurrencyValue';
import { nbsp } from 'lib/html-entities'; import { nbsp } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
...@@ -37,7 +38,7 @@ const WatchListAddressItem = ({ item, isLoading }: { item: WatchlistAddress; isL ...@@ -37,7 +38,7 @@ const WatchListAddressItem = ({ item, isLoading }: { item: WatchlistAddress; isL
isLoading={ isLoading } isLoading={ isLoading }
/> />
<Skeleton isLoaded={ !isLoading } whiteSpace="pre" display="inline-flex"> <Skeleton isLoaded={ !isLoading } whiteSpace="pre" display="inline-flex">
<span>{ config.chain.currency.symbol } balance: </span> <span>{ currencyUnits.ether } balance: </span>
<CurrencyValue <CurrencyValue
value={ item.address_balance } value={ item.address_balance }
exchangeRate={ item.exchange_rate } exchangeRate={ item.exchange_rate }
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { AddressWithdrawalsItem } from 'types/api/address';
import type { BlockWithdrawalsItem } from 'types/api/block';
import type { WithdrawalsItem } from 'types/api/withdrawals';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import WithdrawalsListItem from './WithdrawalsListItem';
type Props = {
isLoading?: boolean;
} & ({
items: Array<WithdrawalsItem>;
view: 'list';
} | {
items: Array<AddressWithdrawalsItem>;
view: 'address';
} | {
items: Array<BlockWithdrawalsItem>;
view: 'block';
});
const WithdrawalsList = ({ items, view, isLoading }: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(items, !isLoading);
return (
<Box>
{ items.slice(0, renderedItemsNum).map((item, index) => {
const key = item.index + (isLoading ? String(index) : '');
switch (view) {
case 'address': {
return (
<WithdrawalsListItem
key={ key }
item={ item as AddressWithdrawalsItem }
view={ view }
isLoading={ isLoading }
/>
);
}
case 'block': {
return (
<WithdrawalsListItem
key={ key }
item={ item as BlockWithdrawalsItem }
view={ view }
isLoading={ isLoading }
/>
);
}
case 'list': {
return (
<WithdrawalsListItem
key={ key }
item={ item as WithdrawalsItem }
view={ view }
isLoading={ isLoading }
/>
);
}
}
}) }
<div ref={ cutRef }/>
</Box>
);
};
export default React.memo(WithdrawalsList);
...@@ -7,6 +7,7 @@ import type { WithdrawalsItem } from 'types/api/withdrawals'; ...@@ -7,6 +7,7 @@ import type { WithdrawalsItem } from 'types/api/withdrawals';
import config from 'configs/app'; import config from 'configs/app';
import dayjs from 'lib/date/dayjs'; import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
...@@ -77,7 +78,7 @@ const WithdrawalsListItem = ({ item, isLoading, view }: Props) => { ...@@ -77,7 +78,7 @@ const WithdrawalsListItem = ({ item, isLoading, view }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label> <ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value> <ListItemMobileGrid.Value>
<CurrencyValue value={ item.amount } currency={ feature.currency.symbol } isLoading={ isLoading }/> <CurrencyValue value={ item.amount } currency={ currencyUnits.ether } isLoading={ isLoading }/>
</ListItemMobileGrid.Value> </ListItemMobileGrid.Value>
</> </>
) } ) }
......
...@@ -6,6 +6,7 @@ import type { BlockWithdrawalsItem } from 'types/api/block'; ...@@ -6,6 +6,7 @@ import type { BlockWithdrawalsItem } from 'types/api/block';
import type { WithdrawalsItem } from 'types/api/withdrawals'; import type { WithdrawalsItem } from 'types/api/withdrawals';
import config from 'configs/app'; import config from 'configs/app';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import { default as Thead } from 'ui/shared/TheadSticky'; import { default as Thead } from 'ui/shared/TheadSticky';
import WithdrawalsTableItem from './WithdrawalsTableItem'; import WithdrawalsTableItem from './WithdrawalsTableItem';
...@@ -26,7 +27,9 @@ const feature = config.features.beaconChain; ...@@ -26,7 +27,9 @@ const feature = config.features.beaconChain;
view: 'block'; view: 'block';
}); });
const WithdrawalsTable = ({ items, isLoading, top, view = 'list' }: Props) => { const WithdrawalsTable = ({ items, isLoading, top, view }: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(items, !isLoading);
if (!feature.isEnabled) { if (!feature.isEnabled) {
return null; return null;
} }
...@@ -44,15 +47,16 @@ const WithdrawalsTable = ({ items, isLoading, top, view = 'list' }: Props) => { ...@@ -44,15 +47,16 @@ const WithdrawalsTable = ({ items, isLoading, top, view = 'list' }: Props) => {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{ view === 'list' && (items as Array<WithdrawalsItem>).map((item, index) => ( { view === 'list' && (items as Array<WithdrawalsItem>).slice(0, renderedItemsNum).map((item, index) => (
<WithdrawalsTableItem key={ item.index + (isLoading ? String(index) : '') } item={ item } view="list" isLoading={ isLoading }/> <WithdrawalsTableItem key={ item.index + (isLoading ? String(index) : '') } item={ item } view="list" isLoading={ isLoading }/>
)) } )) }
{ view === 'address' && (items as Array<AddressWithdrawalsItem>).map((item, index) => ( { view === 'address' && (items as Array<AddressWithdrawalsItem>).slice(0, renderedItemsNum).map((item, index) => (
<WithdrawalsTableItem key={ item.index + (isLoading ? String(index) : '') } item={ item } view="address" isLoading={ isLoading }/> <WithdrawalsTableItem key={ item.index + (isLoading ? String(index) : '') } item={ item } view="address" isLoading={ isLoading }/>
)) } )) }
{ view === 'block' && (items as Array<BlockWithdrawalsItem>).map((item, index) => ( { view === 'block' && (items as Array<BlockWithdrawalsItem>).slice(0, renderedItemsNum).map((item, index) => (
<WithdrawalsTableItem key={ item.index + (isLoading ? String(index) : '') } item={ item } view="block" isLoading={ isLoading }/> <WithdrawalsTableItem key={ item.index + (isLoading ? String(index) : '') } item={ item } view="block" isLoading={ isLoading }/>
)) } )) }
<tr ref={ cutRef }/>
</Tbody> </Tbody>
</Table> </Table>
); );
......
...@@ -10,16 +10,16 @@ import CurrencyValue from 'ui/shared/CurrencyValue'; ...@@ -10,16 +10,16 @@ import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
type Props = ({ type Props = ({
item: WithdrawalsItem; item: WithdrawalsItem;
view: 'list'; view: 'list';
} | { } | {
item: AddressWithdrawalsItem; item: AddressWithdrawalsItem;
view: 'address'; view: 'address';
} | { } | {
item: BlockWithdrawalsItem; item: BlockWithdrawalsItem;
view: 'block'; view: 'block';
}) & { isLoading?: boolean }; }) & { isLoading?: boolean };
const WithdrawalsTableItem = ({ item, view, isLoading }: Props) => { const WithdrawalsTableItem = ({ item, view, isLoading }: Props) => {
return ( return (
......
...@@ -9,17 +9,16 @@ import type { ZkEvmL2TxnBatch } from 'types/api/zkEvmL2TxnBatches'; ...@@ -9,17 +9,16 @@ import type { ZkEvmL2TxnBatch } from 'types/api/zkEvmL2TxnBatches';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
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';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import PrevNext from 'ui/shared/PrevNext'; import PrevNext from 'ui/shared/PrevNext';
import TextSeparator from 'ui/shared/TextSeparator';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
interface Props { interface Props {
...@@ -43,12 +42,8 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => { ...@@ -43,12 +42,8 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
}, [ data, router ]); }, [ data, router ]);
if (isError) { if (isError) {
if (error?.status === 404) { if (error?.status === 404 || error?.status === 422) {
throw Error('Tx Batch not found', { cause: error as unknown as Error }); throwOnResourceLoadError({ isError, error });
}
if (error?.status === 422) {
throw Error('Invalid tx batch number', { cause: error as unknown as Error });
} }
return <DataFetchAlert/>; return <DataFetchAlert/>;
...@@ -91,14 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => { ...@@ -91,14 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
title="Timestamp" title="Timestamp"
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/> <DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>
{ dayjs(data.timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('llll') }
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
title="Verify tx hash" title="Verify tx hash"
......
...@@ -2304,115 +2304,115 @@ ...@@ -2304,115 +2304,115 @@
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz#ea89004119dc42db2e1dba0f97d553f7372f6fcb" resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz#ea89004119dc42db2e1dba0f97d553f7372f6fcb"
integrity sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg== integrity sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==
"@esbuild/android-arm64@0.17.19": "@esbuild/android-arm64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz#bafb75234a5d3d1b690e7c2956a599345e84a2fd" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622"
integrity sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA== integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==
"@esbuild/android-arm@0.17.19": "@esbuild/android-arm@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.19.tgz#5898f7832c2298bc7d0ab53701c57beb74d78b4d" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682"
integrity sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A== integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==
"@esbuild/android-x64@0.17.19": "@esbuild/android-x64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.19.tgz#658368ef92067866d95fb268719f98f363d13ae1" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2"
integrity sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww== integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==
"@esbuild/darwin-arm64@0.17.19": "@esbuild/darwin-arm64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz#584c34c5991b95d4d48d333300b1a4e2ff7be276" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1"
integrity sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg== integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==
"@esbuild/darwin-x64@0.17.19": "@esbuild/darwin-x64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz#7751d236dfe6ce136cce343dce69f52d76b7f6cb" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d"
integrity sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw== integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==
"@esbuild/freebsd-arm64@0.17.19": "@esbuild/freebsd-arm64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz#cacd171665dd1d500f45c167d50c6b7e539d5fd2" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54"
integrity sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ== integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==
"@esbuild/freebsd-x64@0.17.19": "@esbuild/freebsd-x64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz#0769456eee2a08b8d925d7c00b79e861cb3162e4" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e"
integrity sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ== integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==
"@esbuild/linux-arm64@0.17.19": "@esbuild/linux-arm64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz#38e162ecb723862c6be1c27d6389f48960b68edb" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0"
integrity sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg== integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==
"@esbuild/linux-arm@0.17.19": "@esbuild/linux-arm@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz#1a2cd399c50040184a805174a6d89097d9d1559a" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0"
integrity sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA== integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==
"@esbuild/linux-ia32@0.17.19": "@esbuild/linux-ia32@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz#e28c25266b036ce1cabca3c30155222841dc035a" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7"
integrity sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ== integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==
"@esbuild/linux-loong64@0.17.19": "@esbuild/linux-loong64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz#0f887b8bb3f90658d1a0117283e55dbd4c9dcf72" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d"
integrity sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ== integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==
"@esbuild/linux-mips64el@0.17.19": "@esbuild/linux-mips64el@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz#f5d2a0b8047ea9a5d9f592a178ea054053a70289" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231"
integrity sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A== integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==
"@esbuild/linux-ppc64@0.17.19": "@esbuild/linux-ppc64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz#876590e3acbd9fa7f57a2c7d86f83717dbbac8c7" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb"
integrity sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg== integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==
"@esbuild/linux-riscv64@0.17.19": "@esbuild/linux-riscv64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz#7f49373df463cd9f41dc34f9b2262d771688bf09" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6"
integrity sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA== integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==
"@esbuild/linux-s390x@0.17.19": "@esbuild/linux-s390x@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz#e2afd1afcaf63afe2c7d9ceacd28ec57c77f8829" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071"
integrity sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q== integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==
"@esbuild/linux-x64@0.17.19": "@esbuild/linux-x64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz#8a0e9738b1635f0c53389e515ae83826dec22aa4" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338"
integrity sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw== integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==
"@esbuild/netbsd-x64@0.17.19": "@esbuild/netbsd-x64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz#c29fb2453c6b7ddef9a35e2c18b37bda1ae5c462" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1"
integrity sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q== integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==
"@esbuild/openbsd-x64@0.17.19": "@esbuild/openbsd-x64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz#95e75a391403cb10297280d524d66ce04c920691" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae"
integrity sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g== integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==
"@esbuild/sunos-x64@0.17.19": "@esbuild/sunos-x64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz#722eaf057b83c2575937d3ffe5aeb16540da7273" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d"
integrity sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg== integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==
"@esbuild/win32-arm64@0.17.19": "@esbuild/win32-arm64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz#9aa9dc074399288bdcdd283443e9aeb6b9552b6f" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9"
integrity sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag== integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==
"@esbuild/win32-ia32@0.17.19": "@esbuild/win32-ia32@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz#95ad43c62ad62485e210f6299c7b2571e48d2b03" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102"
integrity sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw== integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==
"@esbuild/win32-x64@0.17.19": "@esbuild/win32-x64@0.18.20":
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d"
integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA== integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
"@eslint-community/eslint-utils@^4.2.0": "@eslint-community/eslint-utils@^4.2.0":
version "4.4.0" version "4.4.0"
...@@ -4699,31 +4699,29 @@ ...@@ -4699,31 +4699,29 @@
tiny-glob "^0.2.9" tiny-glob "^0.2.9"
tslib "^2.4.0" tslib "^2.4.0"
"@playwright/experimental-ct-core@1.35.1": "@playwright/experimental-ct-core@1.41.1":
version "1.35.1" version "1.41.1"
resolved "https://registry.yarnpkg.com/@playwright/experimental-ct-core/-/experimental-ct-core-1.35.1.tgz#21cf2b7c60006099f9de38fce7dbd4ff1d7a566c" resolved "https://registry.yarnpkg.com/@playwright/experimental-ct-core/-/experimental-ct-core-1.41.1.tgz#9bad5adb0a048c687a596bd832a258c8ced24ec8"
integrity sha512-NSoUf6JDLeZFy0HiENwA1GkIwZHvg5KrygnZknwWs7O8yksYLsmiuMb09sf2zsZmfYgVen401SNgf3KfekbweA== integrity sha512-d7PxESV29x6W9RYs0mhkXmxr+6FfTbg2Tm/WJZlhgbIP+OLv79uJ8hl8ERsiBBFtH88sR+WmxHBMiZRpfpa6Fw==
dependencies: dependencies:
"@playwright/test" "1.35.1" playwright "1.41.1"
vite "^4.3.9" playwright-core "1.41.1"
vite "^4.4.12"
"@playwright/experimental-ct-react@1.35.1": "@playwright/experimental-ct-react@1.41.1":
version "1.35.1" version "1.41.1"
resolved "https://registry.yarnpkg.com/@playwright/experimental-ct-react/-/experimental-ct-react-1.35.1.tgz#4dd050987c9b7663ceb045fe8b61b9c024d77146" resolved "https://registry.yarnpkg.com/@playwright/experimental-ct-react/-/experimental-ct-react-1.41.1.tgz#4711881caf1ab47acbc3f7c121b87e17f165a46c"
integrity sha512-QXbjAJRr0eJkEsPyC5Q+nQyeVpVE/vrKUbo8yy5uZwHSy7KRsvyGkGPnOV5JMoeyEfK66Sx43I363tgzNByySw== integrity sha512-Ht04RKD/4J69EPHOR4iAWtsOkkqswxonkcEEhniTNflGn30SoPyNww72LJECDrls+7AJayflJf4qe/cK1Ao/ug==
dependencies: dependencies:
"@playwright/experimental-ct-core" "1.35.1" "@playwright/experimental-ct-core" "1.41.1"
"@vitejs/plugin-react" "^4.0.0" "@vitejs/plugin-react" "^4.0.0"
"@playwright/test@1.35.1", "@playwright/test@^1.35.1": "@playwright/test@1.41.1":
version "1.35.1" version "1.41.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.35.1.tgz#a596b61e15b980716696f149cc7a2002f003580c" resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.41.1.tgz#6954139ed4a67999f1b17460aa3d184f4b334f18"
integrity sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA== integrity sha512-9g8EWTjiQ9yFBXc6HjCWe41msLpxEX0KhmfmPl9RPLJdfzL4F0lg2BdJ91O9azFdl11y1pmpwdjBiSxvqc+btw==
dependencies: dependencies:
"@types/node" "*" playwright "1.41.1"
playwright-core "1.35.1"
optionalDependencies:
fsevents "2.3.2"
"@polka/url@^1.0.0-next.20": "@polka/url@^1.0.0-next.20":
version "1.0.0-next.23" version "1.0.0-next.23"
...@@ -6474,10 +6472,12 @@ ...@@ -6474,10 +6472,12 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.4.tgz#54be907698f40de8a45770b48486aa3cbd3adff7" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.4.tgz#54be907698f40de8a45770b48486aa3cbd3adff7"
integrity sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow== integrity sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow==
"@types/node@18.11.18": "@types/node@20.11.0":
version "18.11.18" version "20.11.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.0.tgz#8e0b99e70c0c1ade1a86c4a282f7b7ef87c9552f"
integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA== integrity sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==
dependencies:
undici-types "~5.26.4"
"@types/node@>=12.12.47", "@types/node@>=13.7.0": "@types/node@>=12.12.47", "@types/node@>=13.7.0":
version "20.9.0" version "20.9.0"
...@@ -9665,33 +9665,33 @@ es6-promisify@^5.0.0: ...@@ -9665,33 +9665,33 @@ es6-promisify@^5.0.0:
dependencies: dependencies:
es6-promise "^4.0.3" es6-promise "^4.0.3"
esbuild@^0.17.5: esbuild@^0.18.10:
version "0.17.19" version "0.18.20"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.19.tgz#087a727e98299f0462a3d0bcdd9cd7ff100bd955" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6"
integrity sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw== integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==
optionalDependencies: optionalDependencies:
"@esbuild/android-arm" "0.17.19" "@esbuild/android-arm" "0.18.20"
"@esbuild/android-arm64" "0.17.19" "@esbuild/android-arm64" "0.18.20"
"@esbuild/android-x64" "0.17.19" "@esbuild/android-x64" "0.18.20"
"@esbuild/darwin-arm64" "0.17.19" "@esbuild/darwin-arm64" "0.18.20"
"@esbuild/darwin-x64" "0.17.19" "@esbuild/darwin-x64" "0.18.20"
"@esbuild/freebsd-arm64" "0.17.19" "@esbuild/freebsd-arm64" "0.18.20"
"@esbuild/freebsd-x64" "0.17.19" "@esbuild/freebsd-x64" "0.18.20"
"@esbuild/linux-arm" "0.17.19" "@esbuild/linux-arm" "0.18.20"
"@esbuild/linux-arm64" "0.17.19" "@esbuild/linux-arm64" "0.18.20"
"@esbuild/linux-ia32" "0.17.19" "@esbuild/linux-ia32" "0.18.20"
"@esbuild/linux-loong64" "0.17.19" "@esbuild/linux-loong64" "0.18.20"
"@esbuild/linux-mips64el" "0.17.19" "@esbuild/linux-mips64el" "0.18.20"
"@esbuild/linux-ppc64" "0.17.19" "@esbuild/linux-ppc64" "0.18.20"
"@esbuild/linux-riscv64" "0.17.19" "@esbuild/linux-riscv64" "0.18.20"
"@esbuild/linux-s390x" "0.17.19" "@esbuild/linux-s390x" "0.18.20"
"@esbuild/linux-x64" "0.17.19" "@esbuild/linux-x64" "0.18.20"
"@esbuild/netbsd-x64" "0.17.19" "@esbuild/netbsd-x64" "0.18.20"
"@esbuild/openbsd-x64" "0.17.19" "@esbuild/openbsd-x64" "0.18.20"
"@esbuild/sunos-x64" "0.17.19" "@esbuild/sunos-x64" "0.18.20"
"@esbuild/win32-arm64" "0.17.19" "@esbuild/win32-arm64" "0.18.20"
"@esbuild/win32-ia32" "0.17.19" "@esbuild/win32-ia32" "0.18.20"
"@esbuild/win32-x64" "0.17.19" "@esbuild/win32-x64" "0.18.20"
escalade@^3.1.1: escalade@^3.1.1:
version "3.1.1" version "3.1.1"
...@@ -13426,10 +13426,19 @@ pkg-types@^1.0.3: ...@@ -13426,10 +13426,19 @@ pkg-types@^1.0.3:
mlly "^1.2.0" mlly "^1.2.0"
pathe "^1.1.0" pathe "^1.1.0"
playwright-core@1.35.1: playwright-core@1.41.1:
version "1.35.1" version "1.41.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.35.1.tgz#52c1e6ffaa6a8c29de1a5bdf8cce0ce290ffb81d" resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.41.1.tgz#9c152670010d9d6f970f34b68e3e935d3c487431"
integrity sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg== integrity sha512-/KPO5DzXSMlxSX77wy+HihKGOunh3hqndhqeo/nMxfigiKzogn8kfL0ZBDu0L1RKgan5XHCPmn6zXd2NUJgjhg==
playwright@1.41.1:
version "1.41.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.41.1.tgz#83325f34165840d019355c2a78a50f21ed9b9c85"
integrity sha512-gdZAWG97oUnbBdRL3GuBvX3nDDmUOuqzV/D24dytqlKt+eI5KbwusluZRGljx1YoJKZ2NRPaeWiFTeGZO7SosQ==
dependencies:
playwright-core "1.41.1"
optionalDependencies:
fsevents "2.3.2"
pngjs@^5.0.0: pngjs@^5.0.0:
version "5.0.0" version "5.0.0"
...@@ -13496,7 +13505,7 @@ postcss@8.4.31: ...@@ -13496,7 +13505,7 @@ postcss@8.4.31:
picocolors "^1.0.0" picocolors "^1.0.0"
source-map-js "^1.0.2" source-map-js "^1.0.2"
postcss@^8.4.19, postcss@^8.4.23, postcss@^8.4.32: postcss@^8.4.19, postcss@^8.4.27, postcss@^8.4.32:
version "8.4.32" version "8.4.32"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.32.tgz#1dac6ac51ab19adb21b8b34fd2d93a86440ef6c9" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.32.tgz#1dac6ac51ab19adb21b8b34fd2d93a86440ef6c9"
integrity sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw== integrity sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==
...@@ -14420,10 +14429,10 @@ robust-predicates@^3.0.0: ...@@ -14420,10 +14429,10 @@ robust-predicates@^3.0.0:
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a" resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a"
integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g== integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==
rollup@^3.21.0: rollup@^3.27.1:
version "3.25.1" version "3.29.4"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.25.1.tgz#9fff79d22ff1a904b2b595a2fb9bc3793cb987d8" resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981"
integrity sha512-tywOR+rwIt5m2ZAWSe5AIJcTat8vGlnPFAv15ycCrw33t6iFsXZ6mzHVFh2psSjxQPmI+xgzMZZizUAukBI4aQ== integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
...@@ -15804,14 +15813,14 @@ vite-tsconfig-paths@^3.5.2: ...@@ -15804,14 +15813,14 @@ vite-tsconfig-paths@^3.5.2:
recrawl-sync "^2.0.3" recrawl-sync "^2.0.3"
tsconfig-paths "^4.0.0" tsconfig-paths "^4.0.0"
vite@^4.3.9: vite@^4.4.12:
version "4.3.9" version "4.5.2"
resolved "https://registry.yarnpkg.com/vite/-/vite-4.3.9.tgz#db896200c0b1aa13b37cdc35c9e99ee2fdd5f96d" resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.2.tgz#d6ea8610e099851dad8c7371599969e0f8b97e82"
integrity sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg== integrity sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==
dependencies: dependencies:
esbuild "^0.17.5" esbuild "^0.18.10"
postcss "^8.4.23" postcss "^8.4.27"
rollup "^3.21.0" rollup "^3.27.1"
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
......
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