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

Merge branch 'main' into category-tabs

parents b352e18e 878cb938
{
"name": "blockscout dev",
"image": "mcr.microsoft.com/devcontainers/typescript-node:18",
"image": "mcr.microsoft.com/devcontainers/typescript-node:20",
"forwardPorts": [ 3000 ],
"customizations": {
"vscode": {
......
......@@ -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') }}
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20.11.0
cache: 'yarn'
- name: Cache node_modules
uses: actions/cache@v3
uses: actions/cache@v4
id: cache-node-modules
with:
path: |
......@@ -57,16 +57,16 @@ jobs:
needs: [ code_quality ]
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20.11.0
cache: 'yarn'
- name: Cache node_modules
uses: actions/cache@v3
uses: actions/cache@v4
id: cache-node-modules
with:
path: |
......@@ -94,16 +94,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20.11.0
cache: 'yarn'
- name: Cache node_modules
uses: actions/cache@v3
uses: actions/cache@v4
id: cache-node-modules
with:
path: |
......@@ -122,7 +122,7 @@ jobs:
needs: [ code_quality, envs_validation ]
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.35.1-focal
image: mcr.microsoft.com/playwright:v1.41.1-focal
strategy:
fail-fast: false
......@@ -134,18 +134,18 @@ jobs:
run: apt-get update && apt-get install git-lfs
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
lfs: 'true'
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20.11.0
cache: 'yarn'
- name: Cache node_modules
uses: actions/cache@v3
uses: actions/cache@v4
id: cache-node-modules
with:
path: |
......@@ -164,7 +164,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.project }}
path: playwright-report
......
......@@ -16,16 +16,16 @@ jobs:
if: ${{ github.ref_type == 'tag' }}
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20.11.0
cache: 'yarn'
- name: Cache node_modules
uses: actions/cache@v3
uses: actions/cache@v4
id: cache-node-modules
with:
path: |
......
# *****************************
# *** 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.
RUN apk add --no-cache libc6-compat
......@@ -10,7 +10,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN apk add git
RUN yarn --frozen-lockfile --ignore-optional
RUN yarn --frozen-lockfile
### FEATURE REPORTER
......@@ -30,7 +30,7 @@ RUN yarn --frozen-lockfile
# *****************************
# ****** 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
# pass commit sha and git tag to the app image
......@@ -78,7 +78,7 @@ RUN cd ./deploy/tools/envs-validator && yarn build
# ******* STAGE 3: Run ********
# *****************************
# 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
### APP
......
......@@ -8,6 +8,7 @@ const chain = Object.freeze({
shortName: getEnvValue('NEXT_PUBLIC_NETWORK_SHORT_NAME'),
currency: {
name: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_NAME'),
weiName: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_WEI_NAME'),
symbol: getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL'),
decimals: Number(getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS')) || DEFAULT_CURRENCY_DECIMALS,
},
......
......@@ -20,6 +20,7 @@ export { default as sol2uml } from './sol2uml';
export { default as stats } from './stats';
export { default as suave } from './suave';
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 web3Wallet } from './web3Wallet';
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 type { ChainIndicatorId } from 'types/homepage';
import type { NetworkExplorer } from 'types/networks';
......@@ -66,6 +67,9 @@ const UI = Object.freeze({
explorers: {
items: parseEnvJson<Array<NetworkExplorer>>(getEnvValue('NEXT_PUBLIC_NETWORK_EXPLORERS')) || [],
},
ides: {
items: parseEnvJson<Array<ContractCodeIde>>(getEnvValue('NEXT_PUBLIC_CONTRACT_CODE_IDES')) || [],
},
});
export default UI;
......@@ -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_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true'
NEXT_PUBLIC_HAS_BEACON_CHAIN=true
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_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
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
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
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=
# app features
......
......@@ -11,6 +11,7 @@ import * as yup from 'yup';
import type { AdButlerConfig } from '../../../types/client/adButlerConfig';
import { SUPPORTED_AD_TEXT_PROVIDERS, SUPPORTED_AD_BANNER_PROVIDERS } 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 { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items';
import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items';
......@@ -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
.object({
name: yup.string().required(),
......@@ -331,6 +339,7 @@ const schema = yup
NEXT_PUBLIC_NETWORK_ID: yup.number().positive().integer().required(),
NEXT_PUBLIC_NETWORK_RPC_URL: yup.string().test(urlTest),
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_DECIMALS: yup.number().integer().positive(),
NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL: yup.string(),
......@@ -417,6 +426,11 @@ const schema = yup
.transform(replaceQuotes)
.json()
.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_INT_TXS: yup.boolean(),
NEXT_PUBLIC_MAINTENANCE_ALERT_MESSAGE: yup.string(),
......@@ -447,6 +461,7 @@ const schema = yup
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
// 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
......
......@@ -9,6 +9,7 @@ NEXT_PUBLIC_APP_PORT=3000
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_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_FEATURED_NETWORKS=https://example.com
NEXT_PUBLIC_FOOTER_LINKS=https://example.com
......
......@@ -161,6 +161,7 @@ frontend:
NEXT_PUBLIC_GRAPHIQL_TRANSACTION: 0xf7d4972356e6ae44ae948d0cf19ef2beaf0e574c180997e969a2837da15e349d
NEXT_PUBLIC_WEB3_WALLETS: "['token_pocket','coinbase','metamask']"
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
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
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:
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']"
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_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:
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
......
......@@ -14,7 +14,7 @@ Thanks for showing interest to contribute to Blockscout. The following steps wil
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
node -v
npm -v
......
......@@ -33,6 +33,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Banner ads](ENVS.md#banner-ads)
- [Text ads](ENVS.md#text-ads)
- [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)
- [ZkEvm rollup (L2) chain](NVS.md#zkevm-rollup-l2-chain)
- [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
| 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_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_DECIMALS | `string` | Network currency decimals | - | `18` | `6` |
| NEXT_PUBLIC_NETWORK_GOVERNANCE_TOKEN_SYMBOL | `string` | Network governance token symbol | - | - | `GNO` |
......@@ -258,6 +260,7 @@ Settings for meta tags and OG tags
| 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_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_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! 🤪` |
......@@ -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>`
#### 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;
## App features
......@@ -345,6 +356,14 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
&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
| 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">
<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 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 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 {
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { TTxsFilters } from 'types/api/txsFilters';
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 { VisualizedContract } from 'types/api/visualization';
import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals';
......@@ -579,6 +580,20 @@ export const RESOURCES = {
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
config_backend_version: {
path: '/api/v2/config/backend-version',
......@@ -651,7 +666,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' |
'withdrawals' | 'address_withdrawals' | 'block_withdrawals' |
'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>;
......@@ -754,6 +769,9 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse :
Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -775,6 +793,7 @@ Q extends 'tokens_bridged' ? TokensBridgedFilters :
Q extends 'verified_contracts' ? VerifiedContractsFilters :
Q extends 'addresses_lookup' ? EnsAddressLookupFilters :
Q extends 'domains_lookup' ? EnsDomainLookupFilters :
Q extends 'user_ops' ? UserOpsFilters :
never;
/* eslint-enable @typescript-eslint/indent */
......
......@@ -57,6 +57,7 @@ export default function useApiFetch() {
},
{
resource: resource.path,
omitSentryErrorLog: true, // disable logging of API errors to Sentry
},
);
}, [ fetch, csrfToken ]);
......
......@@ -4,20 +4,22 @@ import React from 'react';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
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() {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
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;
},
retry,
throwOnError: (error) => {
const status = getErrorObjStatusCode(error);
// 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 {
icon: 'transactions',
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 =
{
text: 'Verified contracts',
......@@ -54,7 +61,7 @@ export default function useNavItems(): ReturnType {
isActive: pathname === '/verified-contracts',
};
const ensLookup = config.features.nameService.isEnabled ? {
text: 'ENS lookup',
text: 'Name services lookup',
nextRoute: { pathname: '/name-domains' as const },
icon: 'ENS',
isActive: pathname === '/name-domains' || pathname === '/name-domains/[name]',
......@@ -64,6 +71,7 @@ export default function useNavItems(): ReturnType {
blockchainNavItems = [
[
txs,
userOps,
blocks,
{
text: 'Txn batches',
......@@ -71,7 +79,7 @@ export default function useNavItems(): ReturnType {
icon: 'txn_batches',
isActive: pathname === '/zkevm-l2-txn-batches' || pathname === '/zkevm-l2-txn-batch/[number]',
},
],
].filter(Boolean),
[
topAccounts,
verifiedContracts,
......@@ -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' },
],
[
userOps,
topAccounts,
verifiedContracts,
ensLookup,
......@@ -103,6 +112,7 @@ export default function useNavItems(): ReturnType {
} else {
blockchainNavItems = [
txs,
userOps,
blocks,
topAccounts,
verifiedContracts,
......
......@@ -39,6 +39,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/l2-withdrawals': 'Root page',
'/zkevm-l2-txn-batches': 'Root page',
'/zkevm-l2-txn-batch/[number]': 'Regular page',
'/ops': 'Root page',
'/op/[hash]': 'Regular page',
'/404': 'Regular page',
'/name-domains': 'Root page',
'/name-domains/[name]': 'Regular page',
......
......@@ -42,6 +42,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/l2-withdrawals': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batches': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE,
'/ops': DEFAULT_TEMPLATE,
'/op/[hash]': DEFAULT_TEMPLATE,
'/404': DEFAULT_TEMPLATE,
'/name-domains': DEFAULT_TEMPLATE,
'/name-domains/[name]': DEFAULT_TEMPLATE,
......
......@@ -37,6 +37,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/l2-withdrawals': 'withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%',
'/ops': 'user operations',
'/op/[hash]': 'user operation %hash%',
'/404': 'error - page not found',
'/name-domains': 'domains search and resolve',
'/name-domains/[name]': '%name% domain details',
......
......@@ -37,6 +37,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/l2-withdrawals': 'Withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details',
'/ops': 'User operations',
'/op/[hash]': 'User operation details',
'/404': '404',
'/name-domains': 'Domains search and resolve',
'/name-domains/[name]': 'Domain details',
......
......@@ -2,6 +2,7 @@ import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
import appConfig from 'configs/app';
import { RESOURCE_LOAD_ERROR_MESSAGE } from 'lib/errors/throwOnResourceLoadError';
const feature = appConfig.features.sentry;
......@@ -59,6 +60,9 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
'The quota has been exceeded',
'Attempt to connect to relay via',
'WebSocket connection failed for URL: wss://relay.walletconnect.com',
// API errors
RESOURCE_LOAD_ERROR_MESSAGE,
],
denyUrls: [
// 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 = {
address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
......@@ -101,6 +109,13 @@ export const tx1: SearchResultTx = {
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 = {
items: [
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) => {
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" {
| StaticRoute<"/login">
| DynamicRoute<"/name-domains/[name]", { "name": string }>
| StaticRoute<"/name-domains">
| DynamicRoute<"/op/[hash]", { "hash": string }>
| StaticRoute<"/ops">
| StaticRoute<"/search-results">
| StaticRoute<"/stats">
| DynamicRoute<"/token/[hash]", { "hash": string }>
......
......@@ -4,8 +4,8 @@
"private": false,
"homepage": "https://github.com/blockscout/frontend#readme",
"engines": {
"node": "18",
"npm": "8"
"node": "20.11.0",
"npm": "10.2.4"
},
"scripts": {
"dev": "./tools/scripts/dev.sh",
......@@ -24,7 +24,7 @@
"svg:build-sprite": "icons build -i ./icons -o ./public/icons --optimize",
"test:pw": "./tools/scripts/pw.sh",
"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:jest": "jest",
"test:jest:watch": "jest --watch",
......@@ -99,8 +99,8 @@
"xss": "^1.0.14"
},
"devDependencies": {
"@playwright/experimental-ct-react": "1.35.1",
"@playwright/test": "^1.35.1",
"@playwright/experimental-ct-react": "1.41.1",
"@playwright/test": "1.41.1",
"@svgr/webpack": "^6.5.1",
"@tanstack/eslint-plugin-query": "^5.0.5",
"@testing-library/react": "^14.0.0",
......@@ -112,7 +112,7 @@
"@types/jest": "^29.2.0",
"@types/js-cookie": "^3.0.2",
"@types/mixpanel-browser": "^2.38.1",
"@types/node": "18.11.18",
"@types/node": "20.11.0",
"@types/phoenix": "^1.5.4",
"@types/qrcode": "^1.5.0",
"@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 = {
{ name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' },
{ name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' },
],
userOps: [
{ name: 'NEXT_PUBLIC_HAS_USER_OPS', value: 'true' },
],
};
export const viewsEnvs = {
......
// This file is generated by npm run build:icons
export type IconName =
| "ABI"
| "ABI_slim"
| "ABI"
| "API"
| "apps"
| "arrows/down-right"
......@@ -27,6 +28,7 @@
| "discussions"
| "docs"
| "donate"
| "dots"
| "edit"
| "email-sent"
| "email"
......@@ -125,10 +127,11 @@
| "txn_batches"
| "unfinalized"
| "uniswap"
| "user_op_slim"
| "user_op"
| "verified_token"
| "verified"
| "verify-contract"
| "vertical_dots"
| "wallet"
| "wallets/coinbase"
| "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) => {
return {
container: {
[$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: {
[$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 {
public_tags: Array<AddressTag> | null;
}
export interface AddressParam extends UserTags {
export type AddressParamBasic = {
hash: string;
implementation_name: string | null;
name: string | null;
......@@ -23,3 +23,5 @@ export interface AddressParam extends UserTags {
is_verified: boolean | null;
ens_domain_name: string | null;
}
export type AddressParam = UserTags & AddressParamBasic;
......@@ -13,7 +13,7 @@ export interface Block {
hash: string;
parent_hash: string;
difficulty: string;
total_difficulty: string;
total_difficulty: string | null;
gas_used: string | null;
gas_limit: string;
nonce: string;
......@@ -69,7 +69,7 @@ export type BlockWithdrawalsResponse = {
next_page_params: {
index: number;
items_count: number;
};
} | null;
}
export type BlockWithdrawalsItem = {
......
......@@ -55,7 +55,14 @@ export interface SearchResultTx {
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 {
items: Array<SearchResultItem>;
......@@ -79,5 +86,5 @@ export interface SearchResultFilters {
export interface SearchRedirectResult {
parameter: string | null;
redirect: boolean;
type: 'address' | 'block' | 'transaction' | null;
type: 'address' | 'block' | 'transaction' | 'user_operation' | null;
}
......@@ -26,14 +26,14 @@ export type Transaction = {
hash: string;
result: string;
confirmations: number;
status: 'ok' | 'error' | null;
status: 'ok' | 'error' | null | undefined;
block: number | null;
timestamp: string | null;
confirmation_duration: Array<number>;
confirmation_duration: Array<number> | null;
from: AddressParam;
value: string;
fee: Fee;
gas_price: string;
gas_price: string | null;
type: number | null;
gas_used: string | null;
gas_limit: string;
......@@ -49,7 +49,7 @@ export type Transaction = {
decoded_input: DecodedInput | null;
token_transfers: Array<TokenTransfer> | null;
token_transfers_overflow: boolean;
exchange_rate: string;
exchange_rate: string | null;
method: string | null;
tx_types: Array<TransactionType>;
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';
import { getResourceKey } from 'lib/api/useApiQuery';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { currencyUnits } from 'lib/units';
import { BLOCK } from 'stubs/block';
import { generateListStub } from 'stubs/utils';
import ActionBar from 'ui/shared/ActionBar';
......@@ -95,7 +96,7 @@ const AddressBlocksValidated = ({ scrollRef }: Props) => {
<Th width="16%">Txn</Th>
<Th width="25%">Gas used</Th>
{ !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>
</Thead>
<Tbody>
......
......@@ -7,6 +7,7 @@ import type { Address as TAddress } from 'types/api/address';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_COUNTERS } from 'stubs/address';
import AddressCounterItem from 'ui/address/details/AddressCounterItem';
......@@ -68,7 +69,7 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
const is422Error = addressQuery.isError && 'status' in addressQuery.error && addressQuery.error.status === 422;
if (addressQuery.isError && is422Error) {
throw Error('Address fetch error', { cause: addressQuery.error as unknown as Error });
throwOnResourceLoadError(addressQuery);
}
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) => {
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<Skeleton isLoaded={ !isPlaceholderData }>
<Skeleton isLoaded={ !isPlaceholderData } borderRadius="base">
<Button
className={ className }
color={ scoreColor }
......@@ -122,7 +122,7 @@ const SolidityscanReport = ({ className, hash }: Props) => {
onClick={ onToggle }
aria-label="SolidityScan score"
fontWeight={ 500 }
px={ 2 }
px="6px"
h="32px"
flexShrink={ 0 }
>
......
......@@ -7,6 +7,7 @@ import type { Block } from 'types/api/block';
import config from 'configs/app';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import Utilization from 'ui/shared/Utilization/Utilization';
......@@ -50,7 +51,7 @@ const AddressBlocksValidatedListItem = (props: Props) => {
</Flex>
{ !config.UI.views.block.hiddenFields?.total_reward && (
<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>
</Flex>
) }
......
......@@ -3,6 +3,7 @@ import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { currencyUnits } from 'lib/units';
import ChartWidget from 'ui/shared/chart/ChartWidget';
interface Props {
......@@ -26,7 +27,7 @@ const AddressCoinBalanceChart = ({ addressHash }: Props) => {
items={ items }
isLoading={ isPending }
h="300px"
units={ config.chain.currency.symbol }
units={ currencyUnits.ether }
/>
);
};
......
......@@ -5,8 +5,8 @@ import React from 'react';
import type { AddressCoinBalanceHistoryResponse } from 'types/api/address';
import type { PaginationParams } from 'ui/shared/pagination/types';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import { currencyUnits } from 'lib/units';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
......@@ -32,7 +32,7 @@ const AddressCoinBalanceHistory = ({ query }: Props) => {
<Th width="20%">Block</Th>
<Th width="20%">Txn</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>
</Tr>
</Thead>
......
......@@ -4,9 +4,9 @@ import React from 'react';
import type { AddressCoinBalanceHistoryItem } from 'types/api/address';
import config from 'configs/app';
import { WEI, ZERO } from 'lib/consts';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
......@@ -25,7 +25,7 @@ const AddressCoinBalanceListItem = (props: Props) => {
<ListItemMobile rowGap={ 2 } isAnimated>
<Flex justifyContent="space-between" w="100%">
<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 isLoaded={ !props.isLoading }>
<Stat flexGrow="0">
......
......@@ -122,6 +122,9 @@ test('verified with multiple sources', async({ mount, page }) => {
await page.getByRole('button', { name: 'View external libraries' }).click();
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 }) => {
......
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 { useWeb3Modal, useWeb3ModalState } from '@web3modal/wagmi/react';
import React from 'react';
import { useAccount, useDisconnect } from 'wagmi';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import useWallet from 'ui/snippets/walletMenu/useWallet';
const ContractConnectWallet = () => {
const { open } = useWeb3Modal();
const { open: isOpen } = useWeb3ModalState();
const { disconnect } = useDisconnect();
const { isModalOpening, isModalOpen, connect, disconnect, address, isWalletConnected } = useWallet({ source: 'Smart contracts' });
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 = (() => {
if (isDisconnected || !address) {
if (!isWalletConnected) {
return (
<>
<span>Disconnected</span>
<Button
ml={ 3 }
onClick={ handleConnect }
onClick={ connect }
size="sm"
variant="outline"
isLoading={ isModalOpening || isOpen }
isLoading={ isModalOpening || isModalOpen }
loadingText="Connect wallet"
>
Connect wallet
......@@ -61,7 +39,7 @@ const ContractConnectWallet = () => {
ml={ 2 }
/>
</Flex>
<Button onClick={ handleDisconnect } size="sm" variant="outline">Disconnect</Button>
<Button onClick={ disconnect } size="sm" variant="outline">Disconnect</Button>
</Flex>
);
})();
......
......@@ -39,7 +39,7 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
return [
...('inputs' in data ? data.inputs : []),
...('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,
internalType: 'uint256' as const,
fieldType: 'native_coin' as const,
......
......@@ -5,8 +5,8 @@ import React from 'react';
import type { SmartContractMethodOutput } from 'types/api/contract';
import config from 'configs/app';
import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
function castValueToString(value: number | string | boolean | object | bigint | undefined): string {
......@@ -32,17 +32,17 @@ interface Props {
const ContractMethodStatic = ({ data }: Props) => {
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 initialValue = castValueToString(data.value);
if (event.target.checked) {
setValue(BigNumber(initialValue).div(WEI).toFixed());
setLabel(config.chain.currency.symbol || 'ETH');
setLabel(currencyUnits.ether.toUpperCase());
} else {
setValue(BigNumber(initialValue).toFixed());
setLabel('WEI');
setLabel(currencyUnits.wei.toUpperCase());
}
}, [ data.value ]);
......
......@@ -13,6 +13,7 @@ import LinkInternal from 'ui/shared/LinkInternal';
import CodeEditor from 'ui/shared/monaco/CodeEditor';
import formatFilePath from 'ui/shared/monaco/utils/formatFilePath';
import ContractCodeIdes from './ContractCodeIdes';
import ContractExternalLibraries from './ContractExternalLibraries';
const SOURCE_CODE_OPTIONS = [
......@@ -116,6 +117,8 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
<CopyToClipboard text={ activeContractData[0].source_code } isLoading={ isLoading } ml={{ base: 'auto', lg: diagramLink ? '0' : 'auto' }}/> :
null;
const ides = sourceType === 'secondary' ? <ContractCodeIdes hash={ implementationAddress }/> : <ContractCodeIdes hash={ address }/>;
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setSourceType(event.target.value as SourceCodeType);
}, []);
......@@ -188,6 +191,7 @@ const ContractSourceCode = ({ address, implementationAddress }: Props) => {
{ editorSourceTypeSelector }
{ externalLibraries }
{ diagramLink }
{ ides }
{ copyToClipboard }
</Flex>
{ content }
......
......@@ -129,7 +129,8 @@ function castValue(value: string, type: SmartContractMethodArgType) {
}
const isNestedArray = (type.match(/\[/g) || []).length > 1;
if (isNestedArray) {
const isNestedTuple = type.includes('tuple');
if (isNestedArray || isNestedTuple) {
return parseArrayValue(value) || value;
}
......
......@@ -8,6 +8,7 @@ import config from 'configs/app';
import { getResourceKey } from 'lib/api/useApiQuery';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -66,7 +67,7 @@ const AddressBalance = ({ data, isLoading }: Props) => {
return (
<DetailsInfoItem
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"
alignItems="flex-start"
isLoading={ isLoading }
......@@ -75,7 +76,7 @@ const AddressBalance = ({ data, isLoading }: Props) => {
value={ data.coin_balance || '0' }
exchangeRate={ data.exchange_rate }
decimals={ String(config.chain.currency.decimals) }
currency={ config.chain.currency.symbol }
currency={ currencyUnits.ether }
accuracyUsd={ 2 }
accuracy={ 8 }
flexWrap="wrap"
......
......@@ -31,7 +31,7 @@ test('base view', async({ mount, page }) => {
</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 } });
});
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 React from 'react';
......@@ -50,7 +50,7 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => {
}
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) {
......@@ -95,15 +95,19 @@ const AddressEnsDomains = ({ addressHash, mainDomainName }: Props) => {
variant="outline"
colorScheme="gray"
onClick={ onToggle }
aria-label="Address ENS domains"
aria-label="Address domains"
fontWeight={ 500 }
px={ 2 }
h="32px"
flexShrink={ 0 }
>
<IconSvg name="ENS_slim" boxSize={ 5 }/>
<chakra.span ml={ 1 } display={{ base: 'none', lg: 'block' }}>{ totalRecords } Domain{ data.items.length > 1 ? 's' : '' }</chakra.span>
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 }/>
<Show above="xl">
<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>
</PopoverTrigger>
<PopoverContent w={{ base: '100vw', lg: '500px' }}>
......
......@@ -6,6 +6,7 @@ import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
......@@ -68,7 +69,7 @@ const TxInternalsListItem = ({
w="100%"
/>
<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 }>
<span>{ BigNumber(value).div(BigNumber(10 ** config.chain.currency.decimals)).toFormat() }</span>
</Skeleton>
......
......@@ -3,8 +3,8 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { currencyUnits } from 'lib/units';
import { default as Thead } from 'ui/shared/TheadSticky';
import AddressIntTxsTableItem from './AddressIntTxsTableItem';
......@@ -26,7 +26,7 @@ const AddressIntTxsTable = ({ data, currentAddress, isLoading }: Props) => {
<Th width="10%">Block</Th>
<Th width="40%">From/To</Th>
<Th width="20%" isNumeric>
Value { config.chain.currency.symbol }
Value { currencyUnits.ether }
</Th>
</Tr>
</Thead>
......
......@@ -6,6 +6,7 @@ import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { ZERO } from 'lib/consts';
import getCurrencyValue from 'lib/getCurrencyValue';
import { currencyUnits } from 'lib/units';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import { getTokensTotalInfo } from '../utils/tokenUtils';
......@@ -52,8 +53,8 @@ const TokenBalances = () => {
isLoading={ addressQuery.isPending || tokenQuery.isPending }
/>
<TokenBalancesItem
name={ `${ config.chain.currency.symbol } Balance` }
value={ (!nativeUsd.eq(ZERO) ? `$${ nativeUsd.toFormat(2) } USD | ` : '') + `${ nativeValue } ${ config.chain.currency.symbol }` }
name={ `${ currencyUnits.ether } Balance` }
value={ (!nativeUsd.eq(ZERO) ? `$${ nativeUsd.toFormat(2) } USD | ` : '') + `${ nativeValue } ${ currencyUnits.ether }` }
isLoading={ addressQuery.isPending || tokenQuery.isPending }
/>
<TokenBalancesItem
......
......@@ -6,6 +6,7 @@ import type { AddressesItem } from 'types/api/addresses';
import config from 'configs/app';
import { ZERO } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
......@@ -43,7 +44,7 @@ const AddressesListItem = ({
<Tag key={ tag.label } isLoading={ isLoading }>{ tag.display_name }</Tag>
)) }
<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">
<span>{ addressBalance.dp(8).toFormat() }</span>
</Skeleton>
......
......@@ -4,8 +4,8 @@ import React from 'react';
import type { AddressesItem } from 'types/api/addresses';
import config from 'configs/app';
import { ZERO } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import { default as Thead } from 'ui/shared/TheadSticky';
import AddressesTableItem from './AddressesTableItem';
......@@ -27,7 +27,7 @@ const AddressesTable = ({ items, totalSupply, pageStartIndex, top, isLoading }:
<Th width="64px">Rank</Th>
<Th width={ hasPercentage ? '30%' : '40%' }>Address</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> }
<Th width="15%" isNumeric>Txn count</Th>
</Tr>
......
import { test, expect } from '@playwright/experimental-ct-react';
import type { UseQueryResult } from '@tanstack/react-query';
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 contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as configs from 'playwright/utils/configs';
import BlockDetails from './BlockDetails';
import type { BlockQuery } from './useBlockQuery';
const hooksConfig = {
router: {
......@@ -22,7 +19,7 @@ test('regular block +@mobile +@dark-mode', async({ mount, page }) => {
const query = {
data: blockMock.base,
isPending: false,
} as UseQueryResult<Block, ResourceError>;
} as BlockQuery;
const component = await mount(
<TestApp>
......@@ -40,7 +37,7 @@ test('genesis block', async({ mount, page }) => {
const query = {
data: blockMock.genesis,
isPending: false,
} as UseQueryResult<Block, ResourceError>;
} as BlockQuery;
const component = await mount(
<TestApp>
......@@ -63,7 +60,7 @@ customFieldsTest('rootstock custom fields', async({ mount, page }) => {
const query = {
data: blockMock.rootstock,
isPending: false,
} as UseQueryResult<Block, ResourceError>;
} as BlockQuery;
const component = await mount(
<TestApp>
......
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 capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import type { Block } from 'types/api/block';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import getBlockReward from 'lib/block/getBlockReward';
import { GWEI, WEI, WEI_IN_GWEI, ZERO } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import { space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getQueryParamString from 'lib/router/getQueryParamString';
import { currencyUnits } from 'lib/units';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
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 AddressEntity from 'ui/shared/entities/address/AddressEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
......@@ -32,8 +28,10 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet';
import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization';
import type { BlockQuery } from './useBlockQuery';
interface Props {
query: UseQueryResult<Block, ResourceError>;
query: BlockQuery;
}
const isRollup = config.features.optimisticRollup.isEnabled || config.features.zkEvmRollup.isEnabled;
......@@ -45,7 +43,7 @@ const BlockDetails = ({ query }: Props) => {
const separatorColor = useColorModeValue('gray.200', 'gray.700');
const { data, isPlaceholderData, isError, error } = query;
const { data, isPlaceholderData } = query;
const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag);
......@@ -66,18 +64,6 @@ const BlockDetails = ({ query }: Props) => {
router.push({ pathname: '/block/[height_or_hash]', query: { height_or_hash: nextId } }, undefined);
}, [ 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) {
return null;
}
......@@ -178,14 +164,7 @@ const BlockDetails = ({ query }: Props) => {
hint="Date & time at which block was produced."
isLoading={ isPlaceholderData }
>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>
{ dayjs(data.timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('llll') }
</Skeleton>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Transactions"
......@@ -237,7 +216,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>
{ totalReward.dividedBy(WEI).toFixed() } { config.chain.currency.symbol }
{ totalReward.dividedBy(WEI).toFixed() } { currencyUnits.ether }
</Skeleton>
{ rewardBreakDown }
</DetailsInfoItem>
......@@ -251,7 +230,7 @@ const BlockDetails = ({ query }: Props) => {
// is this text correct for validators?
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>
))
}
......@@ -295,7 +274,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.minimum_gas_price).dividedBy(GWEI).toFormat() } Gwei
{ BigNumber(data.minimum_gas_price).dividedBy(GWEI).toFormat() } { currencyUnits.gwei }
</Skeleton>
</DetailsInfoItem>
) }
......@@ -309,15 +288,15 @@ const BlockDetails = ({ query }: Props) => {
<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">
{ 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>
</>
) }
</DetailsInfoItem>
) }
{ !config.UI.views.block.hiddenFields?.burnt_fees && (
{ !config.UI.views.block.hiddenFields?.burnt_fees && !burntFees.isEqualTo(ZERO) && (
<DetailsInfoItem
title="Burnt fees"
hint={
......@@ -329,7 +308,7 @@ const BlockDetails = ({ query }: Props) => {
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 2 }>
{ burntFees.dividedBy(WEI).toFixed() } { config.chain.currency.symbol }
{ burntFees.dividedBy(WEI).toFixed() } { currencyUnits.ether }
</Skeleton>
{ !txFees.isEqualTo(ZERO) && (
<Tooltip label="Burnt fees / Txn fees * 100%">
......@@ -351,7 +330,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ 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>
</DetailsInfoItem>
) }
......@@ -446,14 +425,16 @@ const BlockDetails = ({ query }: Props) => {
<HashStringShortenDynamic hash={ BigNumber(data.difficulty).toFormat() }/>
</Box>
</DetailsInfoItem>
<DetailsInfoItem
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>
</DetailsInfoItem>
{ data.total_difficulty && (
<DetailsInfoItem
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>
</DetailsInfoItem>
) }
<DetailsInfoItemDivider/>
......
......@@ -3,7 +3,7 @@ import React from 'react';
import DataListDisplay from 'ui/shared/DataListDisplay';
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';
type Props = {
......@@ -14,14 +14,11 @@ const BlockWithdrawals = ({ blockWithdrawalsQuery }: Props) => {
const content = blockWithdrawalsQuery.data?.items ? (
<>
<Show below="lg" ssr={ false }>
{ blockWithdrawalsQuery.data.items.map((item, index) => (
<WithdrawalsListItem
key={ item.index + (blockWithdrawalsQuery.isPlaceholderData ? String(index) : '') }
item={ item }
view="block"
isLoading={ blockWithdrawalsQuery.isPlaceholderData }
/>
)) }
<WithdrawalsList
items={ blockWithdrawalsQuery.data.items }
isLoading={ blockWithdrawalsQuery.isPlaceholderData }
view="block"
/>
</Show>
<Hide below="lg" ssr={ false }>
<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';
import getBlockTotalReward from 'lib/block/getBlockTotalReward';
import { WEI } from 'lib/consts';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import BlockTimestamp from 'ui/blocks/BlockTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
......@@ -94,7 +95,7 @@ const BlocksListItem = ({ data, isLoading, enableTimeIncrement }: Props) => {
</Box>
{ !isRollup && !config.UI.views.block.hiddenFields?.total_reward && (
<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">
<span>{ totalReward.toFixed() }</span>
</Skeleton>
......
......@@ -8,6 +8,7 @@ import type { Block } from 'types/api/block';
import config from 'configs/app';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import BlocksTableItem from 'ui/blocks/BlocksTableItem';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import { default as Thead } from 'ui/shared/TheadSticky';
......@@ -49,9 +50,9 @@ const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum
<Th width="64px" isNumeric>Txn</Th>
<Th width={ `${ GAS_COL_WEIGHT / widthBase * 100 }%` }>Gas used</Th>
{ !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 &&
<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>
</Thead>
<Tbody>
......
......@@ -13,6 +13,7 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus';
......@@ -88,7 +89,7 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
<Flex flexDir="column">
{ !config.UI.views.tx.hiddenFields?.value && (
<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>
</Skeleton>
) }
......
......@@ -12,6 +12,7 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxStatus from 'ui/shared/statusTag/TxStatus';
......@@ -75,13 +76,13 @@ const LatestTxsItem = ({ tx, isLoading }: Props) => {
/>
{ !config.UI.views.tx.hiddenFields?.value && (
<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>
</Skeleton>
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<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 ? (
<TxFeeStability data={ tx.stability_fee } accuracy={ 5 } color="text_secondary" hideUsd/>
) : (
......
......@@ -7,6 +7,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import { HOMEPAGE_STATS } from 'stubs/stats';
import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent';
......@@ -59,7 +60,7 @@ const Stats = () => {
}
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';
......
......@@ -34,7 +34,7 @@ const ChainIndicatorItem = ({ id, title, value, icon, isSelected, onClick, stats
return null;
}
if (stats.isPending) {
if (stats.isPlaceholderData) {
return (
<Skeleton
h={ 3 }
......@@ -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>;
}
......
......@@ -52,11 +52,11 @@ const ChainIndicators = () => {
}
const valueTitle = (() => {
if (statsQueryResult.isPending) {
if (statsQueryResult.isPlaceholderData) {
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>;
}
......
......@@ -128,8 +128,7 @@ const MarketplaceAppCard = ({
fontSize={{ base: 'sm', sm: 'lg' }}
fontWeight="semibold"
fontFamily="heading"
display={ isExperiment ? 'flex' : 'inline-block' }
alignItems={ isExperiment ? 'center' : undefined }
display="inline-block"
>
<MarketplaceAppCardLink
id={ id }
......@@ -148,11 +147,12 @@ const MarketplaceAppCard = ({
>
<IconSvg
name={ integrationIcon }
marginLeft={ 2 }
boxSize={ 5 }
color={ integrationIconColor }
position="relative"
cursor="pointer"
verticalAlign="middle"
marginBottom={ 1 }
/>
</Tooltip>
) }
......
......@@ -13,12 +13,12 @@ type Props = {
const MarketplaceAppCardLink = ({ url, external, id, title, onClick }: Props) => {
return external ? (
<LinkOverlay href={ url } isExternal={ true }>
<LinkOverlay href={ url } isExternal={ true } marginRight={ 2 }>
{ title }
</LinkOverlay>
) : (
<NextLink href={{ pathname: '/apps/[id]', query: { id } }} passHref legacyBehavior>
<LinkOverlay onClick={ onClick }>
<LinkOverlay onClick={ onClick } marginRight={ 2 }>
{ title }
</LinkOverlay>
</NextLink>
......
......@@ -11,6 +11,7 @@ import useContractTabs from 'lib/hooks/useContractTabs';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_INFO, ADDRESS_TABS_COUNTERS } from 'stubs/address';
import { USER_OPS_ACCOUNT } from 'stubs/userOps';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance';
import AddressContract from 'ui/address/AddressContract';
......@@ -20,6 +21,7 @@ import AddressLogs from 'ui/address/AddressLogs';
import AddressTokens from 'ui/address/AddressTokens';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs';
import AddressUserOps from 'ui/address/AddressUserOps';
import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode';
......@@ -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 contractTabs = useContractTabs(addressQuery.data);
......@@ -74,6 +84,14 @@ const AddressPageContent = () => {
count: addressTabsCountersQuery.data?.transactions_count,
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 ?
{
id: 'withdrawals',
......@@ -140,7 +158,7 @@ const AddressPageContent = () => {
subTabs: contractTabs.map(tab => tab.id),
} : undefined,
].filter(Boolean);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data ]);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data ]);
const tags = (
<EntityTags
......@@ -151,6 +169,7 @@ const AddressPageContent = () => {
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined,
userOpsAccountQuery.data ? { label: 'user_ops_acc', display_name: 'Smart contract wallet' } : undefined,
] }
/>
);
......@@ -222,7 +241,10 @@ const AddressPageContent = () => {
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
{ /* should stay before tabs to scroll up with pagination */ }
<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';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
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 useIsMobile from 'lib/hooks/useIsMobile';
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 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 ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import NetworkExplorers from 'ui/shared/NetworkExplorers';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import TxsWithFrontendSorting from 'ui/txs/TxsWithFrontendSorting';
......@@ -39,53 +39,42 @@ const BlockPageContent = () => {
const heightOrHash = getQueryParamString(router.query.height_or_hash);
const tab = getQueryParamString(router.query.tab);
const blockQuery = useApiQuery('block', {
pathParams: { height_or_hash: heightOrHash },
queryOptions: {
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 blockQuery = useBlockQuery({ heightOrHash });
const blockTxsQuery = useBlockTxQuery({ heightOrHash, blockQuery, tab });
const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab });
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) ?
{ 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 ]);
const hasPagination = !isMobile && (
......@@ -113,6 +102,9 @@ const BlockPageContent = () => {
};
}, [ appProps.referrer ]);
throwOnAbsentParamError(heightOrHash);
throwOnResourceLoadError(blockQuery);
const title = (() => {
switch (blockQuery.data?.type) {
case 'reorg':
......
......@@ -5,6 +5,7 @@ import type { SmartContractVerificationMethod } from 'types/api/contract';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractVerificationForm from 'ui/contractVerification/ContractVerificationForm';
import useFormConfigQuery from 'ui/contractVerification/useFormConfigQuery';
......@@ -27,9 +28,7 @@ const ContractVerificationForAddress = () => {
},
});
if (contractQuery.isError && contractQuery.error.status === 404) {
throw Error('Not found', { cause: contractQuery.error as unknown as Error });
}
throwOnResourceLoadError(contractQuery);
const configQuery = useFormConfigQuery(Boolean(hash));
......
......@@ -8,11 +8,12 @@ import type { CsvExportParams } from 'types/client/address';
import type { ResourceName } from 'lib/api/resources';
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 useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import CsvExportForm from 'ui/csvExport/CsvExportForm';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -62,7 +63,8 @@ const CsvExport = () => {
const isMobile = useIsMobile();
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 filterValueFromQuery = router.query.filterValue?.toString();
......@@ -86,17 +88,20 @@ const CsvExport = () => {
};
}, [ appProps.referrer ]);
if (!isCorrectExportType(exportType) || !addressHash || addressQuery.error?.status === 400) {
throw Error('Not found', { cause: { status: 404 } });
throwOnAbsentParamError(addressHash);
throwOnAbsentParamError(exportType);
if (!exportType) {
return null;
}
const filterType = filterTypeFromQuery === EXPORT_TYPES[exportType].filterType ? filterTypeFromQuery : null;
const filterType = filterTypeFromQuery === exportType.filterType ? filterTypeFromQuery : null;
const filterValue = (() => {
if (!filterType || !filterValueFromQuery) {
return null;
}
if (EXPORT_TYPES[exportType].filterValues && !EXPORT_TYPES[exportType].filterValues?.includes(filterValueFromQuery)) {
if (exportType.filterValues && !exportType.filterValues?.includes(filterValueFromQuery)) {
return null;
}
......@@ -104,9 +109,7 @@ const CsvExport = () => {
})();
const content = (() => {
if (addressQuery.isError) {
return <DataFetchAlert/>;
}
throwOnResourceLoadError(addressQuery);
if (addressQuery.isPending) {
return <ContentLoader/>;
......@@ -115,10 +118,10 @@ const CsvExport = () => {
return (
<CsvExportForm
hash={ addressHash }
resource={ EXPORT_TYPES[exportType].resource }
resource={ exportType.resource }
filterType={ filterType }
filterValue={ filterValue }
fileNameTemplate={ EXPORT_TYPES[exportType].fileNameTemplate }
fileNameTemplate={ exportType.fileNameTemplate }
/>
);
})();
......@@ -130,7 +133,7 @@ const CsvExport = () => {
backLink={ backLink }
/>
<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
address={{ hash: addressHash, is_contract: true, implementation_name: null }}
truncation={ isMobile ? 'constant' : 'dynamic' }
......@@ -139,7 +142,7 @@ const CsvExport = () => {
<span>{ nbsp }</span>
{ filterType && filterValue && <span>with applied filter by { filterType } ({ filterValue }) </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>
{ content }
</>
......
......@@ -5,6 +5,7 @@ import { MarketplaceCategory } from 'types/client/marketplace';
import type { TabItem } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useFeatureValue from 'lib/growthbook/useFeatureValue';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceCategoriesMenu from 'ui/marketplace/MarketplaceCategoriesMenu';
......@@ -76,9 +77,7 @@ const Marketplace = () => {
onCategoryChange(categoryTabs[index].id);
}, [ categoryTabs, onCategoryChange ]);
if (isError) {
throw new Error('Unable to get apps list', { cause: error });
}
throwOnResourceLoadError(isError && error ? { isError, error } : { isError: false, error: null });
if (!feature.isEnabled) {
return null;
......
......@@ -10,6 +10,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useApiFetch from 'lib/hooks/useFetch';
import * as metadata from 'lib/metadata';
import getQueryParamString from 'lib/router/getQueryParamString';
......@@ -100,7 +101,7 @@ const MarketplaceApp = () => {
const router = useRouter();
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 ],
queryFn: async() => {
const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(configUrl, undefined, { resource: 'marketplace-apps' });
......@@ -116,6 +117,7 @@ const MarketplaceApp = () => {
},
enabled: feature.isEnabled,
});
const { data, isPending } = query;
useEffect(() => {
if (data) {
......@@ -126,9 +128,7 @@ const MarketplaceApp = () => {
}
}, [ data ]);
if (isError) {
throw new Error('Unable to load app', { cause: error });
}
throwOnResourceLoadError(query);
return (
<DappscoutIframeProvider
......
......@@ -8,6 +8,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { ENS_DOMAIN } from 'stubs/ENS';
......@@ -42,9 +43,7 @@ const NameDomain = () => {
const tabIndex = useTabIndexFromQuery(tabs);
if (infoQuery.isError) {
throw new Error(undefined, { cause: infoQuery.error });
}
throwOnResourceLoadError(infoQuery);
const isLoading = infoQuery.isPlaceholderData;
......@@ -81,7 +80,7 @@ const NameDomain = () => {
return (
<>
<TextAd mb={ 6 }/>
<PageTitle title="ENS Domain details" secondRow={ titleSecondRow }/>
<PageTitle title="Name details" secondRow={ titleSecondRow }/>
{ infoQuery.isPlaceholderData ? (
<>
<TabsSkeleton tabs={ tabs } mt={ 6 }/>
......
......@@ -193,7 +193,7 @@ const NameDomains = () => {
return (
<>
<PageTitle title="ENS domains lookup" withTextAd/>
<PageTitle title="Name services lookup" withTextAd/>
<DataListDisplay
isError={ isError }
items={ data?.items }
......
......@@ -8,6 +8,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import SearchResults from './SearchResults';
......@@ -157,6 +158,36 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
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', () => {
const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || '';
const extendedTest = test.extend({
......
......@@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
import type { FormEvent } from 'react';
import React from 'react';
import config from 'configs/app';
import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps';
import SearchResultListItem from 'ui/searchResults/SearchResultListItem';
import SearchResultsInput from 'ui/searchResults/SearchResultsInput';
......@@ -52,6 +53,12 @@ const SearchResultsPageContent = () => {
router.replace({ pathname: '/tx/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
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 = () => {
event.preventDefault();
}, [ ]);
const displayedItems = (data?.items || []).filter((item) => {
if (!config.features.userOps.isEnabled && item.type === 'user_operation') {
return false;
}
return true;
});
const content = (() => {
if (isError) {
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) {
return null;
......@@ -83,7 +97,7 @@ const SearchResultsPageContent = () => {
searchTerm={ debouncedSearchTerm }
/>
)) }
{ data && data.items.map((item, index) => (
{ displayedItems.map((item, index) => (
<SearchResultListItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
data={ item }
......@@ -110,7 +124,7 @@ const SearchResultsPageContent = () => {
searchTerm={ debouncedSearchTerm }
/>
)) }
{ data && data.items.map((item, index) => (
{ displayedItems.map((item, index) => (
<SearchResultTableItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
data={ item }
......@@ -130,7 +144,7 @@ const SearchResultsPageContent = () => {
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 ? (
<Skeleton h={ 6 } w="280px" borderRadius="full" mb={ pagination.isVisible ? 0 : 6 }/>
......@@ -141,7 +155,7 @@ const SearchResultsPageContent = () => {
<chakra.span fontWeight={ 700 }>
{ resultsCount }
</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>
</Box>
)
......
......@@ -7,6 +7,7 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import useIsMobile from 'lib/hooks/useIsMobile';
import * as metadata from 'lib/metadata';
import * as regexp from 'lib/regexp';
......@@ -129,9 +130,7 @@ const TokenInstanceContent = () => {
) },
].filter(Boolean);
if (tokenInstanceQuery.isError) {
throw Error('Token instance fetch failed', { cause: tokenInstanceQuery.error });
}
throwOnResourceLoadError(tokenInstanceQuery);
const tokenTag = <Tag isLoading={ tokenInstanceQuery.isPlaceholderData }>{ tokenQuery.data?.type }</Tag>;
......
......@@ -4,10 +4,9 @@ import React from 'react';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx';
import TextAd from 'ui/shared/ad/TextAd';
import EntityTags from 'ui/shared/EntityTags';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -15,6 +14,7 @@ import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxDetails from 'ui/tx/TxDetails';
import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded';
import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped';
import TxInternals from 'ui/tx/TxInternals';
import TxLogs from 'ui/tx/TxLogs';
......@@ -22,36 +22,43 @@ import TxRawTrace from 'ui/tx/TxRawTrace';
import TxState from 'ui/tx/TxState';
import TxSubHeading from 'ui/tx/TxSubHeading';
import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import TxUserOps from 'ui/tx/TxUserOps';
import useTxQuery from 'ui/tx/useTxQuery';
const TransactionPageContent = () => {
const router = useRouter();
const appProps = useAppContext();
const hash = getQueryParamString(router.query.hash);
const txQuery = useTxQuery();
const { data, isPlaceholderData, isError, error, errorUpdateCount } = txQuery;
const { data, isPlaceholderData } = useApiQuery('tx', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: TX,
},
});
const tabs: Array<RoutedTab> = [
{
id: 'index',
title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details',
component: <TxDetails/>,
},
config.features.suave.isEnabled && data?.wrapped ?
{ id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } :
undefined,
{ id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer/> },
{ id: 'internal', title: 'Internal txns', component: <TxInternals/> },
{ id: 'logs', title: 'Logs', component: <TxLogs/> },
{ id: 'state', title: 'State', component: <TxState/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace/> },
].filter(Boolean);
const showDegradedView = (isError || isPlaceholderData) && errorUpdateCount > 0;
const tabs: Array<RoutedTab> = (() => {
const detailsComponent = showDegradedView ?
<TxDetailsDegraded hash={ hash } txQuery={ txQuery }/> :
<TxDetails txQuery={ txQuery }/>;
return [
{
id: 'index',
title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details',
component: detailsComponent,
},
config.features.suave.isEnabled && data?.wrapped ?
{ id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } :
undefined,
{ id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer txQuery={ txQuery }/> },
config.features.userOps.isEnabled ?
{ id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } :
undefined,
{ id: 'internal', title: 'Internal txns', component: <TxInternals txQuery={ txQuery }/> },
{ id: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery }/> },
{ id: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> },
].filter(Boolean);
})();
const tabIndex = useTabIndexFromQuery(tabs);
......@@ -77,6 +84,23 @@ const TransactionPageContent = () => {
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 (
<>
<TextAd mb={ 6 }/>
......@@ -86,12 +110,7 @@ const TransactionPageContent = () => {
contentAfter={ tags }
secondRow={ titleSecondRow }
/>
{ isPlaceholderData ? (
<>
<TabsSkeleton tabs={ tabs } mt={ 6 }/>
{ tabs[tabIndex]?.component }
</>
) : <RoutedTabs tabs={ tabs }/> }
{ content }
</>
);
};
......
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';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getCurrencyValue from 'lib/getCurrencyValue';
import { currencyUnits } from 'lib/units';
import { generateListStub } from 'stubs/utils';
import { WITHDRAWAL } from 'stubs/withdrawals';
import DataListDisplay from 'ui/shared/DataListDisplay';
......@@ -64,7 +65,7 @@ const Withdrawals = () => {
{ countersQuery.data && (
<Text lineHeight={{ base: '24px', lg: '32px' }}>
{ 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>
) }
</Skeleton>
......
......@@ -5,6 +5,8 @@ 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 { TX_ZKEVM_L2 } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
......@@ -41,13 +43,8 @@ const ZkEvmL2TxnBatch = () => {
},
});
if (!number) {
throw new Error('Tx batch not found', { cause: { status: 404 } });
}
if (batchQuery.isError) {
throw new Error(undefined, { cause: batchQuery.error });
}
throwOnAbsentParamError(number);
throwOnResourceLoadError(batchQuery);
const tabs: Array<RoutedTab> = React.useMemo(() => ([
{ id: 'index', title: 'Details', component: <ZkEvmL2TxnBatchDetails query={ batchQuery }/> },
......
......@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
......@@ -56,7 +57,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
wordBreak="break-all"
isLoading={ isLoading }
onClick={ handleLinkClick }
flexGrow={ 1 }
overflow="hidden"
>
<Skeleton
......@@ -200,6 +200,26 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
</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) => {
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
);
}
case 'user_operation': {
return (
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
);
}
case 'label': {
return (
<Flex alignItems="center">
......@@ -295,12 +321,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
return (
<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 }
<Skeleton isLoaded={ !isLoading } color="text_secondary" ml={ 8 } textTransform="capitalize">
<span>{ category ? searchItemTitles[category].itemTitleShort : '' }</span>
</Skeleton>
</Flex>
</Grid>
{ Boolean(secondRow) && (
<Box w="100%" overflow="hidden" whiteSpace={ data.type !== 'app' ? 'nowrap' : undefined }>
{ secondRow }
......
......@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal';
......@@ -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 React from 'react';
......@@ -37,16 +37,14 @@ const AccountActionsMenu = ({ isLoading, className }: Props) => {
<Menu>
<Skeleton isLoaded={ !isLoading } borderRadius="base" className={ className }>
<MenuButton
as={ Button }
as={ IconButton }
size="sm"
variant="outline"
colorScheme="gray"
px="7px"
onClick={ handleButtonClick }
>
<Flex alignItems="center">
<span>More</span>
<IconSvg name="arrows/east-mini" transform="rotate(-90deg)" boxSize={ 5 } ml={ 1 }/>
</Flex>
</MenuButton>
icon={ <IconSvg name="dots" boxSize="18px"/> }
/>
</Skeleton>
<MenuList minWidth="180px" zIndex="popover">
{ isTokenPage && config.features.addressVerification.isEnabled &&
......
......@@ -37,7 +37,7 @@ test('status code 500', 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(
<TestApp>
<AppError error={ error }/>
......
......@@ -3,6 +3,7 @@ import React from 'react';
import { route } from 'nextjs-routes';
import getErrorCause from 'lib/errors/getErrorCause';
import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload';
......@@ -36,6 +37,7 @@ const ERROR_TEXTS: Record<string, { title: string; text: string }> = {
const AppError = ({ error, className }: Props) => {
const content = (() => {
const resourceErrorPayload = getResourceErrorPayload(error);
const cause = getErrorCause(error);
const messageInPayload =
resourceErrorPayload &&
typeof resourceErrorPayload === 'object' &&
......@@ -43,8 +45,9 @@ const AppError = ({ error, className }: Props) => {
typeof resourceErrorPayload.message === 'string' ?
resourceErrorPayload.message :
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');
if (isInvalidTxHash) {
......@@ -62,8 +65,6 @@ const AppError = ({ error, className }: Props) => {
return <AppErrorBlockConsensus hash={ hash }/>;
}
const statusCode = getErrorCauseStatusCode(error) || getErrorObjStatusCode(error);
switch (statusCode) {
case 429: {
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';
interface Props {
......@@ -6,21 +8,33 @@ interface Props {
onError?: (error: Error) => void;
}
interface PropsWithRouter extends Props {
router: NextRouter;
}
interface State {
hasError: boolean;
error?: Error;
errorPathname?: string;
}
class ErrorBoundary extends React.PureComponent<Props, State> {
class ErrorBoundary extends React.PureComponent<PropsWithRouter, State> {
state: State = {
hasError: false,
};
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
static getDerivedStateFromProps(props: PropsWithRouter, state: State) {
if (state.hasError && state.errorPathname) {
if (props.router.pathname !== state.errorPathname) {
return { hasError: false, error: undefined, errorPathname: undefined };
}
}
return null;
}
componentDidCatch(error: Error) {
this.setState({ hasError: true, error, errorPathname: this.props.router.pathname });
this.props.onError?.(error);
}
......@@ -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';
import type { GasPriceInfo } from 'types/api/stats';
import { asymp, space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
interface Props {
name: string;
......@@ -18,7 +19,7 @@ const GasInfoRow = ({ name, info }: Props) => {
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 && (
<chakra.span color="text_secondary">
{ 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 type { NetworkExplorer as TNetworkExplorer } from 'types/networks';
......@@ -46,7 +46,12 @@ const NetworkExplorers = ({ className, type, pathParam }: Props) => {
flexShrink={ 0 }
>
<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>
</PopoverTrigger>
<PopoverContent w="240px">
......
......@@ -2,10 +2,10 @@ import { useColorMode } from '@chakra-ui/react';
import { jsonRpcProvider } from '@wagmi/core/providers/jsonRpc';
import { createWeb3Modal, useWeb3ModalTheme, defaultWagmiConfig } from '@web3modal/wagmi/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, WagmiConfig } from 'wagmi';
import config from 'configs/app';
import currentChain from 'lib/web3/currentChain';
import colors from 'theme/foundations/colors';
import { BODY_TYPEFACE } from 'theme/foundations/typography';
import zIndices from 'theme/foundations/zIndices';
......@@ -18,31 +18,6 @@ const getConfig = () => {
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(
[ currentChain ],
[
......
......@@ -96,7 +96,7 @@ const AddressAddToWallet = ({ className, token, isLoading, variant = 'icon', ico
aria-label="Add token to wallet"
variant="outline"
size="sm"
px="6px"
px={ 1 }
onClick={ handleClick }
icon={ <IconSvg name={ WALLETS_INFO[wallet].icon } boxSize={ 6 }/> }
flexShrink={ 0 }
......
......@@ -57,7 +57,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading
w="min-content"
/>
</Flex>
{ to ? (
{ to && (
<Entity
address={ to }
isLoading={ isLoading }
......@@ -70,7 +70,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading
w="min-content"
ml="28px"
/>
) : <span>-</span> }
) }
</Flex>
);
}
......@@ -95,7 +95,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading
isLoading={ isLoading }
type={ getTxCourseType(from.hash, to?.hash, current) }
/>
{ to ? (
{ to && (
<Entity
address={ to }
isLoading={ isLoading }
......@@ -107,7 +107,7 @@ const AddressFromTo = ({ from, to, current, mode: modeProp, className, isLoading
maxW={ truncation === 'constant' ? undefined : `calc(50% - ${ iconSizeWithMargins / 2 }px)` }
ml={ 3 }
/>
) : <span>-</span> }
) }
</Flex>
);
};
......
import type { AddressParam } from 'types/api/addressParams';
export type TxCourseType = 'in' | 'out' | 'self' | 'unspecified';
export function getTxCourseType(from: string, to: string | undefined, current?: string): TxCourseType {
......@@ -19,3 +21,14 @@ export function getTxCourseType(from: string, to: string | undefined, current?:
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,
<MenuButton
w="36px"
h="32px"
icon={ <IconSvg name="vertical_dots" w={ 4 } h={ 4 }/> }
icon={ <IconSvg name="dots" boxSize={ 4 } transform="rotate(-90deg)"/> }
colorScheme="gray"
variant="ghost"
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 { 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 ItemsCategoriesMap =
......@@ -23,6 +25,10 @@ export const searchCategories: Array<{id: Category; title: string }> = [
{ 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 }> = {
app: { itemTitle: 'App', itemTitleShort: 'App' },
token: { itemTitle: 'Token', itemTitleShort: 'Token' },
......@@ -31,6 +37,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
public_tag: { itemTitle: 'Public tag', itemTitleShort: 'Tag' },
transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' },
block: { itemTitle: 'Block', itemTitleShort: 'Block' },
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
};
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
......@@ -57,5 +64,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'app': {
return 'app';
}
case 'user_operation': {
return 'user_operation';
}
}
}
......@@ -12,6 +12,10 @@ export interface Props {
}
const TxStatus = ({ status, errorText, isLoading }: Props) => {
if (status === undefined) {
return null;
}
let text;
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';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/hooks/useFetch';
import useFetch from 'lib/hooks/useFetch';
export default function useNetworkMenu() {
const { isOpen, onClose, onOpen, onToggle } = useDisclosure();
const apiFetch = useApiFetch();
const fetch = useFetch();
const { isPending, data } = useQuery<unknown, ResourceError<unknown>, Array<FeaturedNetwork>>({
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,
staleTime: Infinity,
});
......
......@@ -9,6 +9,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import SearchBar from './SearchBar';
......@@ -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 } });
});
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 }) => {
const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({
......
......@@ -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>;
}
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>;
}
const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
return (
<>
{ resultCategories.length > 1 && (
......
......@@ -12,6 +12,7 @@ import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel';
import SearchBarSuggestToken from './SearchBarSuggestToken';
import SearchBarSuggestTx from './SearchBarSuggestTx';
import SearchBarSuggestUserOp from './SearchBarSuggestUserOp';
interface Props {
data: SearchResultItem;
......@@ -38,6 +39,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'block': {
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) =>
case 'transaction': {
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';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import { HOMEPAGE_STATS } from 'stubs/stats';
import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent';
import TextSeparator from 'ui/shared/TextSeparator';
......@@ -94,7 +95,7 @@ const TopBarStats = () => {
onMouseEnter={ onOpen }
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>
</Tooltip>
</Skeleton>
......
......@@ -17,7 +17,7 @@ type 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 [ isPopoverOpen, setIsPopoverOpen ] = useBoolean(false);
const isMobile = useIsMobile();
......
......@@ -13,7 +13,7 @@ import WalletTooltip from './WalletTooltip';
const WalletMenuMobile = () => {
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 isMobile = useIsMobile();
......
......@@ -4,12 +4,17 @@ import { useAccount, useDisconnect } from 'wagmi';
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: isOpen } = useWeb3ModalState();
const { disconnect } = useDisconnect();
const [ isModalOpening, setIsModalOpening ] = React.useState(false);
const [ isClientLoaded, setIsClientLoaded ] = React.useState(false);
const isConnectionStarted = React.useRef(false);
React.useEffect(() => {
setIsClientLoaded(true);
......@@ -19,12 +24,15 @@ export default function useWallet() {
setIsModalOpening(true);
await open();
setIsModalOpening(false);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: 'Header', Status: 'Started' });
}, [ open ]);
mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Started' });
isConnectionStarted.current = true;
}, [ open, source ]);
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(() => {
disconnect();
......
......@@ -5,6 +5,8 @@ import type { SmartContract } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import ContentLoader from 'ui/shared/ContentLoader';
interface Props {
......@@ -59,23 +61,15 @@ const Sol2UmlDiagram = ({ addressHash }: Props) => {
newWindow?.document.write(image.outerHTML);
}, [ imgUrl ]);
if (!addressHash) {
throw Error('Contract address is not provided', { cause: { status: 404 } as unknown as Error });
}
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 });
}
throwOnAbsentParamError(addressHash);
throwOnResourceLoadError(contractQuery);
throwOnResourceLoadError(umlQuery);
if (contractQuery.isPending || umlQuery.isPending) {
return <ContentLoader/>;
}
if (!umlQuery.data.svg) {
if (!umlQuery.data?.svg || !contractQuery.data) {
return <span>No data for visualization</span>;
}
......
......@@ -9,6 +9,7 @@ import type { TokenInfo } from 'types/api/token';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getCurrencyValue from 'lib/getCurrencyValue';
import { TOKEN_COUNTERS } from 'stubs/token';
import type { TokenTabs } from 'ui/pages/Token';
......@@ -63,9 +64,7 @@ const TokenDetails = ({ tokenQuery }: Props) => {
);
}, [ tokenCountersQuery.data, tokenCountersQuery.isPlaceholderData, changeUrlAndScroll ]);
if (tokenQuery.isError) {
throw Error('Token fetch error', { cause: tokenQuery.error as unknown as Error });
}
throwOnResourceLoadError(tokenQuery);
const {
exchange_rate: exchangeRate,
......
......@@ -27,7 +27,7 @@ const TokenProjectInfo = ({ data }: Props) => {
if (isMobile) {
return (
<>
<TriggerButton isOpen={ isOpen } onClick={ onToggle }/>
<TriggerButton onClick={ onToggle }/>
<Modal isOpen={ isOpen } onClose={ onClose } size="full">
<ModalContent>
<ModalCloseButton/>
......@@ -41,7 +41,7 @@ const TokenProjectInfo = ({ data }: Props) => {
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<TriggerButton isOpen={ isOpen } onClick={ onToggle }/>
<TriggerButton onClick={ onToggle }/>
</PopoverTrigger>
<PopoverContent w="500px">
<PopoverBody px={ 6 } py={ 5 }>
......
......@@ -5,10 +5,9 @@ import IconSvg from 'ui/shared/IconSvg';
interface Props {
onClick: () => void;
isOpen: boolean;
}
const TriggerButton = ({ isOpen, onClick }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
const TriggerButton = ({ onClick }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
return (
<Button
ref={ ref }
......@@ -23,7 +22,6 @@ const TriggerButton = ({ isOpen, onClick }: Props, ref: React.ForwardedRef<HTMLB
>
<IconSvg name="rocket" boxSize={ 5 } mr={ 1 }/>
<span>Info</span>
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } transitionDuration="faster" boxSize={ 5 } ml={ 1 }/>
</Button>
);
};
......
......@@ -28,7 +28,7 @@ const TokenVerifiedInfo = ({ verifiedInfoQuery }: Props) => {
<>
<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 { 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';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
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;
interface Props {
txQuery: TxQuery;
}
const TxDetails = ({ txQuery }: Props) => {
return (
<>
{ config.chain.isTestnet && (
<Skeleton mb={ 6 } isLoaded={ !isPlaceholderData }>
<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>
<TestnetWarning mb={ 6 } isLoading={ txQuery.isPlaceholderData }/>
<TxInfo data={ txQuery.data } isLoading={ txQuery.isPlaceholderData } socketStatus={ txQuery.socketStatus }/>
</>
);
};
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';
import type { Transaction } from 'types/api/transaction';
import type { ExcludeUndefined } from 'types/utils';
import config from 'configs/app';
import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -61,7 +61,7 @@ const TxDetailsWrapped = ({ data }: Props) => {
>
<CurrencyValue
value={ data.value }
currency={ config.chain.currency.symbol }
currency={ currencyUnits.ether }
flexWrap="wrap"
/>
</DetailsInfoItem>
......@@ -72,7 +72,7 @@ const TxDetailsWrapped = ({ data }: Props) => {
>
<CurrencyValue
value={ data.fee.value }
currency={ config.chain.currency.symbol }
currency={ currencyUnits.ether }
flexWrap="wrap"
/>
</DetailsInfoItem>
......
......@@ -7,9 +7,9 @@ import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TxInternals from './TxInternals';
import type { TxQuery } from './useTxQuery';
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 hooksConfig = {
router: {
......@@ -18,18 +18,20 @@ const hooksConfig = {
};
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({
status: 200,
body: JSON.stringify(internalTxsMock.baseResponse),
}));
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
const component = await mount(
<TestApp>
<TxInternals/>
<TxInternals txQuery={ txQuery }/>
</TestApp>,
{ hooksConfig },
);
......
......@@ -3,7 +3,6 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import { SECOND } from 'lib/consts';
// import { apos } from 'lib/html-entities';
import { INTERNAL_TX } from 'stubs/internalTx';
import { generateListStub } from 'stubs/utils';
......@@ -19,7 +18,8 @@ import TxInternalsTable from 'ui/tx/internals/TxInternalsTable';
import type { Sort, SortField } from 'ui/tx/internals/utils';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
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>> = {
value: [ 'value-desc', 'value-asc', undefined ],
......@@ -62,17 +62,20 @@ const sortFn = (sort: Sort | undefined) => (a: InternalTransaction, b: InternalT
// item.to.hash.toLowerCase().includes(formattedSearchTerm);
// };
const TxInternals = () => {
interface Props {
txQuery: TxQuery;
}
const TxInternals = ({ txQuery }: Props) => {
// filters are not implemented yet in api
// const [ filters, setFilters ] = React.useState<Array<TxInternalsType>>([]);
// const [ searchTerm, setSearchTerm ] = React.useState<string>('');
const [ sort, setSort ] = React.useState<Sort>();
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_internal_txs',
pathParams: { hash: txInfo.data?.hash },
pathParams: { hash: txQuery.data?.hash },
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 }),
},
});
......@@ -90,8 +93,8 @@ const TxInternals = () => {
};
}, [ isPlaceholderData ]);
if (!txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data?.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data?.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
const filteredData = data?.items
......@@ -125,7 +128,7 @@ const TxInternals = () => {
return (
<DataListDisplay
isError={ isError || txInfo.isError }
isError={ isError || txQuery.isError }
items={ data?.items }
emptyText="There are no internal transactions for this transaction."
// filterProps={{
......
import { Box, Text } from '@chakra-ui/react';
import React from 'react';
import { SECOND } from 'lib/consts';
import type { Log } from 'types/api/log';
import { LOG } from 'stubs/log';
import { generateListStub } from 'stubs/utils';
import ActionBar from 'ui/shared/ActionBar';
......@@ -11,28 +12,43 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxLogs = () => {
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
import type { TxQuery } from './useTxQuery';
interface Props {
txQuery: TxQuery;
logsFilter?: (log: Log) => boolean;
}
const TxLogs = ({ txQuery, logsFilter }: Props) => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_logs',
pathParams: { hash: txInfo.data?.hash },
pathParams: { hash: txQuery.data?.hash },
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 }),
},
});
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
if (isError || txInfo.isError) {
if (isError || txQuery.isError) {
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>;
}
......@@ -43,7 +59,7 @@ const TxLogs = () => {
<Pagination ml="auto" { ...pagination }/>
</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>
);
};
......
......@@ -5,7 +5,6 @@ import type { SocketMessage } from 'lib/socket/types';
import type { RawTracesResponse } from 'types/api/rawTrace';
import useApiQuery from 'lib/api/useApiQuery';
import { SECOND } from 'lib/consts';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
......@@ -14,19 +13,23 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
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 [ rawTraces, setRawTraces ] = React.useState<RawTracesResponse>();
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const { data, isPlaceholderData, isError } = useApiQuery('tx_raw_trace', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash) && Boolean(txInfo.data?.status) && isQueryEnabled,
enabled: Boolean(hash) && Boolean(txQuery.data?.status) && isQueryEnabled,
placeholderData: TX_RAW_TRACE,
},
});
......@@ -39,7 +42,7 @@ const TxRawTrace = () => {
const channel = useSocketChannel({
topic: `transactions:${ hash }`,
isDisabled: !hash || txInfo.isPlaceholderData || !txInfo.data?.status,
isDisabled: !hash || txQuery.isPlaceholderData || !txQuery.data?.status,
onJoin: enableQuery,
onSocketError: enableQuery,
});
......@@ -49,11 +52,11 @@ const TxRawTrace = () => {
handler: handleRawTraceMessage,
});
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
if (isError || txInfo.isError) {
if (isError || txQuery.isError) {
return <DataFetchAlert/>;
}
......
......@@ -7,8 +7,8 @@ import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
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 hooksConfig = {
router: {
......@@ -17,18 +17,20 @@ const hooksConfig = {
};
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({
status: 200,
body: JSON.stringify(txStateMock.baseResponse),
}));
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
const component = await mount(
<TestApp>
<TxState/>
<TxState txQuery={ txQuery }/>
</TestApp>,
{ hooksConfig },
);
......
import { Accordion, Hide, Show, Text } from '@chakra-ui/react';
import React from 'react';
import { SECOND } from 'lib/consts';
import { TX_STATE_CHANGES } from 'stubs/txStateChanges';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
......@@ -9,18 +8,21 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxStateList from 'ui/tx/state/TxStateList';
import TxStateTable from 'ui/tx/state/TxStateTable';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import TxPendingAlert from './TxPendingAlert';
import TxSocketAlert from './TxSocketAlert';
import type { TxQuery } from './useTxQuery';
const TxState = () => {
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
interface Props {
txQuery: TxQuery;
}
const TxState = ({ txQuery }: Props) => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_state_changes',
pathParams: { hash: txInfo.data?.hash },
pathParams: { hash: txQuery.data?.hash },
options: {
enabled: !txInfo.isPlaceholderData && Boolean(txInfo.data?.hash) && Boolean(txInfo.data?.status),
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: {
items: TX_STATE_CHANGES,
next_page_params: {
......@@ -31,8 +33,8 @@ const TxState = () => {
},
});
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
const content = data ? (
......@@ -54,12 +56,14 @@ const TxState = () => {
return (
<>
<Text mb={ 6 }>
A set of information that represents the current state is updated when a transaction takes place on the network.
The below is a summary of those changes.
</Text>
{ !isError && !txQuery.isError && (
<Text mb={ 6 }>
A set of information that represents the current state is updated when a transaction takes place on the network.
The below is a summary of those changes.
</Text>
) }
<DataListDisplay
isError={ isError }
isError={ isError || txQuery.isError }
items={ data?.items }
emptyText="There are no state changes for this transaction."
content={ content }
......
......@@ -3,8 +3,8 @@ import { useRouter } from 'next/router';
import React from 'react';
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 { apos } from 'lib/html-entities';
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
......@@ -19,22 +19,26 @@ import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
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 TxTokenTransfer = () => {
const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
interface Props {
txQuery: TxQuery;
tokenTransferFilter?: (data: TokenTransfer) => boolean;
}
const TxTokenTransfer = ({ txQuery, tokenTransferFilter }: Props) => {
const router = useRouter();
const [ typeFilter, setTypeFilter ] = React.useState<Array<TokenType>>(getTokenFilterValue(router.query.type) || []);
const tokenTransferQuery = useQueryWithPages({
resourceName: 'tx_token_transfers',
pathParams: { hash: txsInfo.data?.hash.toString() },
pathParams: { hash: txQuery.data?.hash.toString() },
options: {
enabled: !txsInfo.isPlaceholderData && Boolean(txsInfo.data?.status && txsInfo.data?.hash),
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.status && txQuery.data?.hash),
placeholderData: getTokenTransfersStub(),
},
filters: { type: typeFilter },
......@@ -45,24 +49,34 @@ const TxTokenTransfer = () => {
setTypeFilter(nextValue);
}, [ tokenTransferQuery ]);
if (!txsInfo.isPending && !txsInfo.isPlaceholderData && !txsInfo.isError && !txsInfo.data.status) {
return txsInfo.socketStatus ? <TxSocketAlert status={ txsInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
if (txsInfo.isError || tokenTransferQuery.isError) {
if (txQuery.isError || tokenTransferQuery.isError) {
return <DataFetchAlert/>;
}
const numActiveFilters = typeFilter.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 ? (
<>
<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>
<Show below="lg" ssr={ false }>
<TokenTransferList data={ tokenTransferQuery.data?.items } isLoading={ tokenTransferQuery.isPlaceholderData }/>
<TokenTransferList data={ items } isLoading={ tokenTransferQuery.isPlaceholderData }/>
</Show>
</>
) : null;
......@@ -81,8 +95,8 @@ const TxTokenTransfer = () => {
return (
<DataListDisplay
isError={ txsInfo.isError || tokenTransferQuery.isError }
items={ tokenTransferQuery.data?.items }
isError={ txQuery.isError || tokenTransferQuery.isError }
items={ items }
emptyText="There are no token transfers."
filterProps={{
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';
import React from 'react';
import config from 'configs/app';
import { currencyUnits } from 'lib/units';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
interface Props {
......@@ -24,7 +25,7 @@ const TxDetailsFeePerGas = ({ txFee, gasUsed, isLoading }: Props) => {
>
<Skeleton isLoaded={ !isLoading } mr={ 1 }>
{ 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>
</DetailsInfoItem>
);
......
......@@ -4,15 +4,16 @@ import React from 'react';
import config from 'configs/app';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
interface Props {
gasPrice: string;
gasPrice: string | null;
isLoading?: boolean;
}
const TxDetailsGasPrice = ({ gasPrice, isLoading }: Props) => {
if (config.UI.views.tx.hiddenFields?.gas_price) {
if (config.UI.views.tx.hiddenFields?.gas_price || !gasPrice) {
return null;
}
......@@ -23,10 +24,10 @@ const TxDetailsGasPrice = ({ gasPrice, isLoading }: Props) => {
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } mr={ 1 }>
{ BigNumber(gasPrice).dividedBy(WEI).toFixed() } { config.chain.currency.symbol }
{ BigNumber(gasPrice).dividedBy(WEI).toFixed() } { currencyUnits.ether }
</Skeleton>
<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>
</DetailsInfoItem>
);
......
......@@ -4,12 +4,10 @@ import React from 'react';
import * as txMock from 'mocks/txs/tx';
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 TxDetails from './TxDetails';
import TxInfo from './TxInfo';
const API_URL = buildApiUrl('tx', { hash: '1' });
const hooksConfig = {
router: {
query: { hash: 1 },
......@@ -17,14 +15,9 @@ const hooksConfig = {
};
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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.base } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -38,14 +31,9 @@ test('between addresses +@mobile +@dark-mode', 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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withContractCreation } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -57,14 +45,9 @@ test('creating contact', 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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -76,14 +59,9 @@ test('with token transfer +@mobile', 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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withDecodedRevertReason } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -95,14 +73,9 @@ test('with decoded revert 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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withRawRevertReason } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -114,14 +87,9 @@ test('with decoded raw reason', 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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.pending } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -135,14 +103,9 @@ test('pending', 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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withActionsUniswap } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -159,14 +122,9 @@ const l2Test = test.extend({
});
l2Test('l2', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.l2tx),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -185,14 +143,9 @@ const mainnetTest = test.extend({
});
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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -209,14 +162,9 @@ const stabilityTest = test.extend({
});
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(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.stabilityTx } isLoading={ false }/>
</TestApp>,
{ 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';
import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import Tag from 'ui/shared/chakra/Tag';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
......@@ -31,7 +32,7 @@ const TxInternalsListItem = ({ type, from, to, value, success, error, gas_limit:
fontWeight="500"
/>
<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">
{ BigNumber(value).div(BigNumber(10 ** config.chain.currency.decimals)).toFormat() }
</Skeleton>
......
......@@ -3,8 +3,8 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import config from 'configs/app';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { currencyUnits } from 'lib/units';
import IconSvg from 'ui/shared/IconSvg';
import { default as Thead } from 'ui/shared/TheadSticky';
import TxInternalsTableItem from 'ui/tx/internals/TxInternalsTableItem';
......@@ -31,13 +31,13 @@ const TxInternalsTable = ({ data, sort, onSortToggle, top, isLoading }: Props) =
<Th width="16%" isNumeric>
<Link display="flex" alignItems="center" justifyContent="flex-end" onClick={ onSortToggle('value') } columnGap={ 1 }>
{ sort?.includes('value') && <IconSvg name="arrows/east" boxSize={ 4 } transform={ sortIconTransform }/> }
Value { config.chain.currency.symbol }
Value { currencyUnits.ether }
</Link>
</Th>
<Th width="16%" isNumeric>
<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 }/> }
Gas limit { config.chain.currency.symbol }
Gas limit { currencyUnits.ether }
</Link>
</Th>
</Tr>
......
......@@ -4,9 +4,9 @@ import React from 'react';
import type { TxInterpretationSummary, TxInterpretationVariable } from 'types/api/txInterpretation';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import * as mixpanel from 'lib/mixpanel/index';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import IconSvg from 'ui/shared/IconSvg';
......@@ -102,7 +102,7 @@ const TxInterpretation = ({ summary, isLoading, className }: Props) => {
<Text color="text_secondary">{ chunk.trim() + (chunk.trim() && variablesNames[index] ? ' ' : '') }</Text>
{ index < variablesNames.length && (
variablesNames[index] === NATIVE_COIN_SYMBOL_VAR_NAME ?
<Text>{ config.chain.currency.symbol + ' ' }</Text> :
<Text>{ currencyUnits.ether + ' ' }</Text> :
<TxInterpretationElementByType variable={ variables[variablesNames[index]] }/>
) }
</Flex>
......
......@@ -8,6 +8,7 @@ import config from 'configs/app';
import { ZERO_ADDRESS } from 'lib/consts';
import { nbsp, space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag';
import NftEntity from 'ui/shared/entities/nft/NftEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
......@@ -59,12 +60,12 @@ export function getStateElements(data: TxStateChange, isLoading?: boolean) {
return {
before: (
<Skeleton isLoaded={ !isLoading } wordBreak="break-all" display="inline-block">
{ beforeBn.toFormat() } { config.chain.currency.symbol }
{ beforeBn.toFormat() } { currencyUnits.ether }
</Skeleton>
),
after: (
<Skeleton isLoaded={ !isLoading } wordBreak="break-all" display="inline-block">
{ afterBn.toFormat() } { config.chain.currency.symbol }
{ afterBn.toFormat() } { currencyUnits.ether }
</Skeleton>
),
change: (
......
import { useBoolean } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
......@@ -9,44 +10,63 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { retry } from 'lib/api/useQueryClientConfig';
import { SECOND } from 'lib/consts';
import delay from 'lib/delay';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { TX, TX_ZKEVM_L2 } from 'stubs/tx';
interface Params {
onTxStatusUpdate?: () => void;
updateDelay?: number;
export type TxQuery = UseQueryResult<Transaction, ResourceError<{ status: number }>> & {
socketStatus: 'close' | 'error' | undefined;
setRefetchOnError: {
on: () => void;
off: () => void;
toggle: () => void;
};
}
type ReturnType = UseQueryResult<Transaction, ResourceError<{ status: number }>> & {
socketStatus: 'close' | 'error' | undefined;
interface Params {
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 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', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
enabled: Boolean(hash) && params?.isEnabled !== false,
refetchOnMount: false,
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() => {
updateDelay && await delay(updateDelay);
await delay(5 * SECOND);
queryClient.invalidateQueries({
queryKey: getResourceKey('tx', { pathParams: { hash } }),
});
onTxStatusUpdate?.();
}, [ onTxStatusUpdate, queryClient, hash, updateDelay ]);
}, [ queryClient, hash ]);
const handleSocketClose = React.useCallback(() => {
setSocketStatus('close');
......@@ -60,7 +80,7 @@ export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay }: Params
topic: `transactions:${ hash }`,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: isPending || isError || data.status !== null,
isDisabled: isPending || isPlaceholderData || isError || data.status !== null,
});
useSocketMessage({
channel,
......@@ -68,8 +88,9 @@ export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay }: Params
handler: handleStatusUpdateMessage,
});
return {
return React.useMemo(() => ({
...queryResult,
socketStatus,
};
setRefetchOnError: setRefetchEnabled,
}), [ queryResult, socketStatus, setRefetchEnabled ]);
}
......@@ -8,6 +8,7 @@ import { route } from 'nextjs-routes';
import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import LinkInternal from 'ui/shared/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator';
......@@ -42,7 +43,7 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
<Flex>
<CurrencyValue
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 }
accuracyUsd={ 2 }
flexWrap="wrap"
......@@ -68,7 +69,7 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
{ !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) && (
<Box { ...sectionProps } mb={ 4 }>
<Text { ...sectionTitleProps }>Gas fees (Gwei)</Text>
<Text { ...sectionTitleProps }>Gas fees ({ currencyUnits.gwei })</Text>
{ tx.base_fee_per_gas !== null && (
<Box>
<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 type { AddressFromToFilter } from 'types/api/address';
......@@ -8,11 +8,10 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import DataListDisplay from 'ui/shared/DataListDisplay';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import getNextSortValue from 'ui/shared/sort/getNextSortValue';
import TxsHeaderMobile from './TxsHeaderMobile';
import TxsListItem from './TxsListItem';
import TxsList from './TxsList';
import TxsTable from './TxsTable';
const SORT_SEQUENCE: Record<TransactionsSortingField, Array<TransactionsSortingValue | undefined>> = {
......@@ -66,26 +65,16 @@ const TxsContent = ({
const content = items ? (
<>
<Show below="lg" ssr={ false }>
<Box>
{ showSocketInfo && (
<SocketNewItemsNotice.Mobile
url={ window.location.href }
num={ socketInfoNum }
alert={ socketInfoAlert }
isLoading={ isPlaceholderData }
/>
) }
{ items.map((tx, index) => (
<TxsListItem
key={ tx.hash + (isPlaceholderData ? index : '') }
tx={ tx }
showBlockInfo={ showBlockInfo }
currentAddress={ currentAddress }
enableTimeIncrement={ enableTimeIncrement }
isLoading={ isPlaceholderData }
/>
)) }
</Box>
<TxsList
showBlockInfo={ showBlockInfo }
showSocketInfo={ showSocketInfo }
socketInfoAlert={ socketInfoAlert }
socketInfoNum={ socketInfoNum }
isLoading={ isPlaceholderData }
enableTimeIncrement={ enableTimeIncrement }
currentAddress={ currentAddress }
items={ items }
/>
</Show>
<Hide below="lg" ssr={ false }>
<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';
import getValueWithUnit from 'lib/getValueWithUnit';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import AddressFromTo from 'ui/shared/address/AddressFromTo';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
......@@ -95,7 +96,7 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI
<Skeleton isLoaded={ !isLoading } display="inline-block" variant="text_secondary" whiteSpace="pre">
{ getValueWithUnit(tx.value).toFormat() }
{ space }
{ config.chain.currency.symbol }
{ currencyUnits.ether }
</Skeleton>
</Flex>
) }
......@@ -109,7 +110,7 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI
) : (
<Skeleton isLoaded={ !isLoading } display="inline-block" variant="text_secondary" whiteSpace="pre">
{ 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>
) }
</>
......
......@@ -6,6 +6,8 @@ import type { Transaction, TransactionsSortingField, TransactionsSortingValue }
import config from 'configs/app';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import { currencyUnits } from 'lib/units';
import IconSvg from 'ui/shared/IconSvg';
import * as SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import TheadSticky from 'ui/shared/TheadSticky';
......@@ -39,6 +41,8 @@ const TxsTable = ({
enableTimeIncrement,
isLoading,
}: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(txs, !isLoading);
return (
<AddressHighlightProvider>
<Table variant="simple" minWidth="950px" size="xs">
......@@ -55,7 +59,7 @@ const TxsTable = ({
<Link onClick={ sort('value') } display="flex" justifyContent="end">
{ sorting === 'value-asc' && <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>
</Th>
) }
......@@ -64,7 +68,7 @@ const TxsTable = ({
<Link onClick={ sort('fee') } display="flex" justifyContent="end">
{ sorting === 'fee-asc' && <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>
</Th>
) }
......@@ -80,7 +84,7 @@ const TxsTable = ({
/>
) }
<AnimatePresence initial={ false }>
{ txs.map((item, index) => (
{ txs.slice(0, renderedItemsNum).map((item, index) => (
<TxsTableItem
key={ item.hash + (isLoading ? index : '') }
tx={ item }
......@@ -93,6 +97,7 @@ const TxsTable = ({
</AnimatePresence>
</Tbody>
</Table>
<div ref={ cutRef }/>
</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';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import HashStringShorten from 'ui/shared/HashStringShorten';
......@@ -37,7 +38,7 @@ const VerifiedContractsListItem = ({ data, isLoading }: Props) => {
<CopyToClipboard text={ data.address.hash } isLoading={ isLoading }/>
</Flex>
<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">
<span>{ balance }</span>
</Skeleton>
......
......@@ -4,7 +4,7 @@ import React from 'react';
import type { VerifiedContract } from 'types/api/contracts';
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 getNextSortValue from 'ui/shared/sort/getNextSortValue';
import { default as Thead } from 'ui/shared/TheadSticky';
......@@ -35,7 +35,7 @@ const VerifiedContractsTable = ({ data, sort, setSorting, isLoading }: Props) =>
<Th width="130px" isNumeric>
<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 }/> }
Balance { config.chain.currency.symbol }
Balance { currencyUnits.ether }
</Link>
</Th>
<Th width="130px" isNumeric>
......
......@@ -7,6 +7,7 @@ import type { WatchlistAddress } from 'types/api/account';
import config from 'configs/app';
import getCurrencyValue from 'lib/getCurrencyValue';
import { nbsp } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
......@@ -37,7 +38,7 @@ const WatchListAddressItem = ({ item, isLoading }: { item: WatchlistAddress; isL
isLoading={ isLoading }
/>
<Skeleton isLoaded={ !isLoading } whiteSpace="pre" display="inline-flex">
<span>{ config.chain.currency.symbol } balance: </span>
<span>{ currencyUnits.ether } balance: </span>
<CurrencyValue
value={ item.address_balance }
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';
import config from 'configs/app';
import dayjs from 'lib/date/dayjs';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
......@@ -77,7 +78,7 @@ const WithdrawalsListItem = ({ item, isLoading, view }: Props) => {
<ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<CurrencyValue value={ item.amount } currency={ feature.currency.symbol } isLoading={ isLoading }/>
<CurrencyValue value={ item.amount } currency={ currencyUnits.ether } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
</>
) }
......
......@@ -6,6 +6,7 @@ import type { BlockWithdrawalsItem } from 'types/api/block';
import type { WithdrawalsItem } from 'types/api/withdrawals';
import config from 'configs/app';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import { default as Thead } from 'ui/shared/TheadSticky';
import WithdrawalsTableItem from './WithdrawalsTableItem';
......@@ -26,7 +27,9 @@ const feature = config.features.beaconChain;
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) {
return null;
}
......@@ -44,15 +47,16 @@ const WithdrawalsTable = ({ items, isLoading, top, view = 'list' }: Props) => {
</Tr>
</Thead>
<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 }/>
)) }
{ 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 }/>
)) }
{ 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 }/>
)) }
<tr ref={ cutRef }/>
</Tbody>
</Table>
);
......
......@@ -10,16 +10,16 @@ import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
type Props = ({
item: WithdrawalsItem;
view: 'list';
} | {
item: AddressWithdrawalsItem;
view: 'address';
} | {
item: BlockWithdrawalsItem;
view: 'block';
}) & { isLoading?: boolean };
type Props = ({
item: WithdrawalsItem;
view: 'list';
} | {
item: AddressWithdrawalsItem;
view: 'address';
} | {
item: BlockWithdrawalsItem;
view: 'block';
}) & { isLoading?: boolean };
const WithdrawalsTableItem = ({ item, view, isLoading }: Props) => {
return (
......
......@@ -9,17 +9,16 @@ import type { ZkEvmL2TxnBatch } from 'types/api/zkEvmL2TxnBatches';
import { route } from 'nextjs-routes';
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 DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/LinkInternal';
import PrevNext from 'ui/shared/PrevNext';
import TextSeparator from 'ui/shared/TextSeparator';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
interface Props {
......@@ -43,12 +42,8 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
}, [ data, router ]);
if (isError) {
if (error?.status === 404) {
throw Error('Tx Batch not found', { cause: error as unknown as Error });
}
if (error?.status === 422) {
throw Error('Invalid tx batch number', { cause: error as unknown as Error });
if (error?.status === 404 || error?.status === 422) {
throwOnResourceLoadError({ isError, error });
}
return <DataFetchAlert/>;
......@@ -91,14 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
title="Timestamp"
isLoading={ isPlaceholderData }
>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>
{ dayjs(data.timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('llll') }
</Skeleton>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Verify tx hash"
......
......@@ -2304,115 +2304,115 @@
resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz#ea89004119dc42db2e1dba0f97d553f7372f6fcb"
integrity sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==
"@esbuild/android-arm64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz#bafb75234a5d3d1b690e7c2956a599345e84a2fd"
integrity sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==
"@esbuild/android-arm@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.19.tgz#5898f7832c2298bc7d0ab53701c57beb74d78b4d"
integrity sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==
"@esbuild/android-x64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.19.tgz#658368ef92067866d95fb268719f98f363d13ae1"
integrity sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==
"@esbuild/darwin-arm64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz#584c34c5991b95d4d48d333300b1a4e2ff7be276"
integrity sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==
"@esbuild/darwin-x64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz#7751d236dfe6ce136cce343dce69f52d76b7f6cb"
integrity sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==
"@esbuild/freebsd-arm64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz#cacd171665dd1d500f45c167d50c6b7e539d5fd2"
integrity sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==
"@esbuild/freebsd-x64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz#0769456eee2a08b8d925d7c00b79e861cb3162e4"
integrity sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==
"@esbuild/linux-arm64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz#38e162ecb723862c6be1c27d6389f48960b68edb"
integrity sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==
"@esbuild/linux-arm@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz#1a2cd399c50040184a805174a6d89097d9d1559a"
integrity sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==
"@esbuild/linux-ia32@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz#e28c25266b036ce1cabca3c30155222841dc035a"
integrity sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==
"@esbuild/linux-loong64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz#0f887b8bb3f90658d1a0117283e55dbd4c9dcf72"
integrity sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==
"@esbuild/linux-mips64el@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz#f5d2a0b8047ea9a5d9f592a178ea054053a70289"
integrity sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==
"@esbuild/linux-ppc64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz#876590e3acbd9fa7f57a2c7d86f83717dbbac8c7"
integrity sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==
"@esbuild/linux-riscv64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz#7f49373df463cd9f41dc34f9b2262d771688bf09"
integrity sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==
"@esbuild/linux-s390x@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz#e2afd1afcaf63afe2c7d9ceacd28ec57c77f8829"
integrity sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==
"@esbuild/linux-x64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz#8a0e9738b1635f0c53389e515ae83826dec22aa4"
integrity sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==
"@esbuild/netbsd-x64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz#c29fb2453c6b7ddef9a35e2c18b37bda1ae5c462"
integrity sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==
"@esbuild/openbsd-x64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz#95e75a391403cb10297280d524d66ce04c920691"
integrity sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==
"@esbuild/sunos-x64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz#722eaf057b83c2575937d3ffe5aeb16540da7273"
integrity sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==
"@esbuild/win32-arm64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz#9aa9dc074399288bdcdd283443e9aeb6b9552b6f"
integrity sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==
"@esbuild/win32-ia32@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz#95ad43c62ad62485e210f6299c7b2571e48d2b03"
integrity sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==
"@esbuild/win32-x64@0.17.19":
version "0.17.19"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061"
integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==
"@esbuild/android-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622"
integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==
"@esbuild/android-arm@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682"
integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==
"@esbuild/android-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2"
integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==
"@esbuild/darwin-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1"
integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==
"@esbuild/darwin-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d"
integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==
"@esbuild/freebsd-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54"
integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==
"@esbuild/freebsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e"
integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==
"@esbuild/linux-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0"
integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==
"@esbuild/linux-arm@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0"
integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==
"@esbuild/linux-ia32@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7"
integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==
"@esbuild/linux-loong64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d"
integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==
"@esbuild/linux-mips64el@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231"
integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==
"@esbuild/linux-ppc64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb"
integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==
"@esbuild/linux-riscv64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6"
integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==
"@esbuild/linux-s390x@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071"
integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==
"@esbuild/linux-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338"
integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==
"@esbuild/netbsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1"
integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==
"@esbuild/openbsd-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae"
integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==
"@esbuild/sunos-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d"
integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==
"@esbuild/win32-arm64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9"
integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==
"@esbuild/win32-ia32@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102"
integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==
"@esbuild/win32-x64@0.18.20":
version "0.18.20"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d"
integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==
"@eslint-community/eslint-utils@^4.2.0":
version "4.4.0"
......@@ -4699,31 +4699,29 @@
tiny-glob "^0.2.9"
tslib "^2.4.0"
"@playwright/experimental-ct-core@1.35.1":
version "1.35.1"
resolved "https://registry.yarnpkg.com/@playwright/experimental-ct-core/-/experimental-ct-core-1.35.1.tgz#21cf2b7c60006099f9de38fce7dbd4ff1d7a566c"
integrity sha512-NSoUf6JDLeZFy0HiENwA1GkIwZHvg5KrygnZknwWs7O8yksYLsmiuMb09sf2zsZmfYgVen401SNgf3KfekbweA==
"@playwright/experimental-ct-core@1.41.1":
version "1.41.1"
resolved "https://registry.yarnpkg.com/@playwright/experimental-ct-core/-/experimental-ct-core-1.41.1.tgz#9bad5adb0a048c687a596bd832a258c8ced24ec8"
integrity sha512-d7PxESV29x6W9RYs0mhkXmxr+6FfTbg2Tm/WJZlhgbIP+OLv79uJ8hl8ERsiBBFtH88sR+WmxHBMiZRpfpa6Fw==
dependencies:
"@playwright/test" "1.35.1"
vite "^4.3.9"
playwright "1.41.1"
playwright-core "1.41.1"
vite "^4.4.12"
"@playwright/experimental-ct-react@1.35.1":
version "1.35.1"
resolved "https://registry.yarnpkg.com/@playwright/experimental-ct-react/-/experimental-ct-react-1.35.1.tgz#4dd050987c9b7663ceb045fe8b61b9c024d77146"
integrity sha512-QXbjAJRr0eJkEsPyC5Q+nQyeVpVE/vrKUbo8yy5uZwHSy7KRsvyGkGPnOV5JMoeyEfK66Sx43I363tgzNByySw==
"@playwright/experimental-ct-react@1.41.1":
version "1.41.1"
resolved "https://registry.yarnpkg.com/@playwright/experimental-ct-react/-/experimental-ct-react-1.41.1.tgz#4711881caf1ab47acbc3f7c121b87e17f165a46c"
integrity sha512-Ht04RKD/4J69EPHOR4iAWtsOkkqswxonkcEEhniTNflGn30SoPyNww72LJECDrls+7AJayflJf4qe/cK1Ao/ug==
dependencies:
"@playwright/experimental-ct-core" "1.35.1"
"@playwright/experimental-ct-core" "1.41.1"
"@vitejs/plugin-react" "^4.0.0"
"@playwright/test@1.35.1", "@playwright/test@^1.35.1":
version "1.35.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.35.1.tgz#a596b61e15b980716696f149cc7a2002f003580c"
integrity sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA==
"@playwright/test@1.41.1":
version "1.41.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.41.1.tgz#6954139ed4a67999f1b17460aa3d184f4b334f18"
integrity sha512-9g8EWTjiQ9yFBXc6HjCWe41msLpxEX0KhmfmPl9RPLJdfzL4F0lg2BdJ91O9azFdl11y1pmpwdjBiSxvqc+btw==
dependencies:
"@types/node" "*"
playwright-core "1.35.1"
optionalDependencies:
fsevents "2.3.2"
playwright "1.41.1"
"@polka/url@^1.0.0-next.20":
version "1.0.0-next.23"
......@@ -6474,10 +6472,12 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.4.tgz#54be907698f40de8a45770b48486aa3cbd3adff7"
integrity sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow==
"@types/node@18.11.18":
version "18.11.18"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f"
integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==
"@types/node@20.11.0":
version "20.11.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.0.tgz#8e0b99e70c0c1ade1a86c4a282f7b7ef87c9552f"
integrity sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==
dependencies:
undici-types "~5.26.4"
"@types/node@>=12.12.47", "@types/node@>=13.7.0":
version "20.9.0"
......@@ -9665,33 +9665,33 @@ es6-promisify@^5.0.0:
dependencies:
es6-promise "^4.0.3"
esbuild@^0.17.5:
version "0.17.19"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.19.tgz#087a727e98299f0462a3d0bcdd9cd7ff100bd955"
integrity sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==
esbuild@^0.18.10:
version "0.18.20"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6"
integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==
optionalDependencies:
"@esbuild/android-arm" "0.17.19"
"@esbuild/android-arm64" "0.17.19"
"@esbuild/android-x64" "0.17.19"
"@esbuild/darwin-arm64" "0.17.19"
"@esbuild/darwin-x64" "0.17.19"
"@esbuild/freebsd-arm64" "0.17.19"
"@esbuild/freebsd-x64" "0.17.19"
"@esbuild/linux-arm" "0.17.19"
"@esbuild/linux-arm64" "0.17.19"
"@esbuild/linux-ia32" "0.17.19"
"@esbuild/linux-loong64" "0.17.19"
"@esbuild/linux-mips64el" "0.17.19"
"@esbuild/linux-ppc64" "0.17.19"
"@esbuild/linux-riscv64" "0.17.19"
"@esbuild/linux-s390x" "0.17.19"
"@esbuild/linux-x64" "0.17.19"
"@esbuild/netbsd-x64" "0.17.19"
"@esbuild/openbsd-x64" "0.17.19"
"@esbuild/sunos-x64" "0.17.19"
"@esbuild/win32-arm64" "0.17.19"
"@esbuild/win32-ia32" "0.17.19"
"@esbuild/win32-x64" "0.17.19"
"@esbuild/android-arm" "0.18.20"
"@esbuild/android-arm64" "0.18.20"
"@esbuild/android-x64" "0.18.20"
"@esbuild/darwin-arm64" "0.18.20"
"@esbuild/darwin-x64" "0.18.20"
"@esbuild/freebsd-arm64" "0.18.20"
"@esbuild/freebsd-x64" "0.18.20"
"@esbuild/linux-arm" "0.18.20"
"@esbuild/linux-arm64" "0.18.20"
"@esbuild/linux-ia32" "0.18.20"
"@esbuild/linux-loong64" "0.18.20"
"@esbuild/linux-mips64el" "0.18.20"
"@esbuild/linux-ppc64" "0.18.20"
"@esbuild/linux-riscv64" "0.18.20"
"@esbuild/linux-s390x" "0.18.20"
"@esbuild/linux-x64" "0.18.20"
"@esbuild/netbsd-x64" "0.18.20"
"@esbuild/openbsd-x64" "0.18.20"
"@esbuild/sunos-x64" "0.18.20"
"@esbuild/win32-arm64" "0.18.20"
"@esbuild/win32-ia32" "0.18.20"
"@esbuild/win32-x64" "0.18.20"
escalade@^3.1.1:
version "3.1.1"
......@@ -13426,10 +13426,19 @@ pkg-types@^1.0.3:
mlly "^1.2.0"
pathe "^1.1.0"
playwright-core@1.35.1:
version "1.35.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.35.1.tgz#52c1e6ffaa6a8c29de1a5bdf8cce0ce290ffb81d"
integrity sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==
playwright-core@1.41.1:
version "1.41.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.41.1.tgz#9c152670010d9d6f970f34b68e3e935d3c487431"
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:
version "5.0.0"
......@@ -13496,7 +13505,7 @@ postcss@8.4.31:
picocolors "^1.0.0"
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"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.32.tgz#1dac6ac51ab19adb21b8b34fd2d93a86440ef6c9"
integrity sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==
......@@ -14420,10 +14429,10 @@ robust-predicates@^3.0.0:
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.1.tgz#ecde075044f7f30118682bd9fb3f123109577f9a"
integrity sha512-ndEIpszUHiG4HtDsQLeIuMvRsDnn8c8rYStabochtUeCvfuvNptb5TUbVD68LRAILPX7p9nqQGh4xJgn3EHS/g==
rollup@^3.21.0:
version "3.25.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.25.1.tgz#9fff79d22ff1a904b2b595a2fb9bc3793cb987d8"
integrity sha512-tywOR+rwIt5m2ZAWSe5AIJcTat8vGlnPFAv15ycCrw33t6iFsXZ6mzHVFh2psSjxQPmI+xgzMZZizUAukBI4aQ==
rollup@^3.27.1:
version "3.29.4"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981"
integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==
optionalDependencies:
fsevents "~2.3.2"
......@@ -15804,14 +15813,14 @@ vite-tsconfig-paths@^3.5.2:
recrawl-sync "^2.0.3"
tsconfig-paths "^4.0.0"
vite@^4.3.9:
version "4.3.9"
resolved "https://registry.yarnpkg.com/vite/-/vite-4.3.9.tgz#db896200c0b1aa13b37cdc35c9e99ee2fdd5f96d"
integrity sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==
vite@^4.4.12:
version "4.5.2"
resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.2.tgz#d6ea8610e099851dad8c7371599969e0f8b97e82"
integrity sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==
dependencies:
esbuild "^0.17.5"
postcss "^8.4.23"
rollup "^3.21.0"
esbuild "^0.18.10"
postcss "^8.4.27"
rollup "^3.27.1"
optionalDependencies:
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