Commit 8d97e7ca authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #1504 from blockscout/user-ops

user ops list and user op details
parents 652620a7 a7a0a395
...@@ -20,6 +20,7 @@ export { default as sol2uml } from './sol2uml'; ...@@ -20,6 +20,7 @@ export { default as sol2uml } from './sol2uml';
export { default as stats } from './stats'; export { default as stats } from './stats';
export { default as suave } from './suave'; export { default as suave } from './suave';
export { default as txInterpretation } from './txInterpretation'; export { default as txInterpretation } from './txInterpretation';
export { default as web3Wallet } from './web3Wallet'; export { default as userOps } from './userOps';
export { default as verifiedTokens } from './verifiedTokens'; export { default as verifiedTokens } from './verifiedTokens';
export { default as web3Wallet } from './web3Wallet';
export { default as zkEvmRollup } from './zkEvmRollup'; export { default as zkEvmRollup } from './zkEvmRollup';
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const title = 'User operations';
const config: Feature<{ isEnabled: true }> = (() => {
if (getEnvValue('NEXT_PUBLIC_HAS_USER_OPS') === 'true') {
return Object.freeze({
title,
isEnabled: true,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
...@@ -49,6 +49,7 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com ...@@ -49,6 +49,7 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens-rs-test.k8s-dev.blockscout.com NEXT_PUBLIC_NAME_SERVICE_API_HOST=https://bens-rs-test.k8s-dev.blockscout.com
NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask'] NEXT_PUBLIC_WEB3_WALLETS=['token_pocket','metamask']
NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true' NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true'
NEXT_PUBLIC_HAS_USER_OPS='true'
#meta #meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth-goerli.png?raw=true NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth-goerli.png?raw=true
...@@ -447,6 +447,7 @@ const schema = yup ...@@ -447,6 +447,7 @@ const schema = yup
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest), NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest),
NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(), NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(),
NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(),
// 6. External services envs // 6. External services envs
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(),
......
...@@ -73,6 +73,7 @@ frontend: ...@@ -73,6 +73,7 @@ frontend:
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']" NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']"
NEXT_PUBLIC_USE_NEXT_JS_PROXY: true NEXT_PUBLIC_USE_NEXT_JS_PROXY: true
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]" NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_HAS_USER_OPS: true
envFromSecret: envFromSecret:
NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN
SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI
......
...@@ -33,6 +33,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -33,6 +33,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
- [Banner ads](ENVS.md#banner-ads) - [Banner ads](ENVS.md#banner-ads)
- [Text ads](ENVS.md#text-ads) - [Text ads](ENVS.md#text-ads)
- [Beacon chain](ENVS.md#beacon-chain) - [Beacon chain](ENVS.md#beacon-chain)
- [User operations](ENVS.md#user-operations-feature-erc-4337)
- [Optimistic rollup (L2) chain](ENVS.md#optimistic-rollup-l2-chain) - [Optimistic rollup (L2) chain](ENVS.md#optimistic-rollup-l2-chain)
- [ZkEvm rollup (L2) chain](NVS.md#zkevm-rollup-l2-chain) - [ZkEvm rollup (L2) chain](NVS.md#zkevm-rollup-l2-chain)
- [Export data to CSV file](ENVS.md#export-data-to-csv-file) - [Export data to CSV file](ENVS.md#export-data-to-csv-file)
...@@ -346,6 +347,14 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi ...@@ -346,6 +347,14 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
&nbsp; &nbsp;
### User operations feature (ERC-4337)
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_HAS_USER_OPS | `boolean` | Set to true to show user operations related data and pages | - | - | `true` |
&nbsp;
### Optimistic rollup (L2) chain ### Optimistic rollup (L2) chain
| Variable | Type| Description | Compulsoriness | Default value | Example value | | Variable | Type| Description | Compulsoriness | Default value | Example value |
......
<svg xmlns="http://www.w3.org/2000/svg" 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.001ZM12.226 20.523a3.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>
...@@ -77,6 +77,7 @@ import type { ...@@ -77,6 +77,7 @@ import type {
import type { TxInterpretationResponse } from 'types/api/txInterpretation'; import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { TTxsFilters } from 'types/api/txsFilters'; import type { TTxsFilters } from 'types/api/txsFilters';
import type { TxStateChanges } from 'types/api/txStateChanges'; import type { TxStateChanges } from 'types/api/txStateChanges';
import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps';
import type { VerifiedContractsSorting } from 'types/api/verifiedContracts'; import type { VerifiedContractsSorting } from 'types/api/verifiedContracts';
import type { VisualizedContract } from 'types/api/visualization'; import type { VisualizedContract } from 'types/api/visualization';
import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals'; import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals';
...@@ -579,6 +580,20 @@ export const RESOURCES = { ...@@ -579,6 +580,20 @@ export const RESOURCES = {
filterFields: [], filterFields: [],
}, },
// USER OPS
user_ops: {
path: '/api/v2/proxy/account-abstraction/operations',
filterFields: [ 'transaction_hash' as const, 'sender' as const ],
},
user_op: {
path: '/api/v2/proxy/account-abstraction/operations/:hash',
pathParams: [ 'hash' as const ],
},
user_ops_account: {
path: '/api/v2/proxy/account-abstraction/accounts/:hash',
pathParams: [ 'hash' as const ],
},
// CONFIGS // CONFIGS
config_backend_version: { config_backend_version: {
path: '/api/v2/config/backend-version', path: '/api/v2/config/backend-version',
...@@ -651,7 +666,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -651,7 +666,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' | 'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' |
'withdrawals' | 'address_withdrawals' | 'block_withdrawals' | 'withdrawals' | 'address_withdrawals' | 'block_withdrawals' |
'watchlist' | 'private_tags_address' | 'private_tags_tx' | 'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup'; 'domains_lookup' | 'addresses_lookup' | 'user_ops';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -754,6 +769,9 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse : ...@@ -754,6 +769,9 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed : Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse : Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse : Q extends 'domains_lookup' ? EnsDomainLookupResponse :
Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -775,6 +793,7 @@ Q extends 'tokens_bridged' ? TokensBridgedFilters : ...@@ -775,6 +793,7 @@ Q extends 'tokens_bridged' ? TokensBridgedFilters :
Q extends 'verified_contracts' ? VerifiedContractsFilters : Q extends 'verified_contracts' ? VerifiedContractsFilters :
Q extends 'addresses_lookup' ? EnsAddressLookupFilters : Q extends 'addresses_lookup' ? EnsAddressLookupFilters :
Q extends 'domains_lookup' ? EnsDomainLookupFilters : Q extends 'domains_lookup' ? EnsDomainLookupFilters :
Q extends 'user_ops' ? UserOpsFilters :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
......
...@@ -46,6 +46,13 @@ export default function useNavItems(): ReturnType { ...@@ -46,6 +46,13 @@ export default function useNavItems(): ReturnType {
icon: 'transactions', icon: 'transactions',
isActive: pathname === '/txs' || pathname === '/tx/[hash]', isActive: pathname === '/txs' || pathname === '/tx/[hash]',
}; };
const userOps: NavItem | null = config.features.userOps.isEnabled ? {
text: 'User operations',
nextRoute: { pathname: '/ops' as const },
icon: 'user_op',
isActive: pathname === '/ops' || pathname === '/op/[hash]',
} : null;
const verifiedContracts: NavItem | null = const verifiedContracts: NavItem | null =
{ {
text: 'Verified contracts', text: 'Verified contracts',
...@@ -64,6 +71,7 @@ export default function useNavItems(): ReturnType { ...@@ -64,6 +71,7 @@ export default function useNavItems(): ReturnType {
blockchainNavItems = [ blockchainNavItems = [
[ [
txs, txs,
userOps,
blocks, blocks,
{ {
text: 'Txn batches', text: 'Txn batches',
...@@ -71,7 +79,7 @@ export default function useNavItems(): ReturnType { ...@@ -71,7 +79,7 @@ export default function useNavItems(): ReturnType {
icon: 'txn_batches', icon: 'txn_batches',
isActive: pathname === '/zkevm-l2-txn-batches' || pathname === '/zkevm-l2-txn-batch/[number]', isActive: pathname === '/zkevm-l2-txn-batches' || pathname === '/zkevm-l2-txn-batch/[number]',
}, },
], ].filter(Boolean),
[ [
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
...@@ -95,6 +103,7 @@ export default function useNavItems(): ReturnType { ...@@ -95,6 +103,7 @@ export default function useNavItems(): ReturnType {
{ text: 'Output roots', nextRoute: { pathname: '/l2-output-roots' as const }, icon: 'output_roots', isActive: pathname === '/l2-output-roots' }, { text: 'Output roots', nextRoute: { pathname: '/l2-output-roots' as const }, icon: 'output_roots', isActive: pathname === '/l2-output-roots' },
], ],
[ [
userOps,
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
ensLookup, ensLookup,
...@@ -103,6 +112,7 @@ export default function useNavItems(): ReturnType { ...@@ -103,6 +112,7 @@ export default function useNavItems(): ReturnType {
} else { } else {
blockchainNavItems = [ blockchainNavItems = [
txs, txs,
userOps,
blocks, blocks,
topAccounts, topAccounts,
verifiedContracts, verifiedContracts,
......
...@@ -39,6 +39,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -39,6 +39,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/l2-withdrawals': 'Root page', '/l2-withdrawals': 'Root page',
'/zkevm-l2-txn-batches': 'Root page', '/zkevm-l2-txn-batches': 'Root page',
'/zkevm-l2-txn-batch/[number]': 'Regular page', '/zkevm-l2-txn-batch/[number]': 'Regular page',
'/ops': 'Root page',
'/op/[hash]': 'Regular page',
'/404': 'Regular page', '/404': 'Regular page',
'/name-domains': 'Root page', '/name-domains': 'Root page',
'/name-domains/[name]': 'Regular page', '/name-domains/[name]': 'Regular page',
......
...@@ -42,6 +42,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -42,6 +42,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/l2-withdrawals': DEFAULT_TEMPLATE, '/l2-withdrawals': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batches': DEFAULT_TEMPLATE, '/zkevm-l2-txn-batches': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE, '/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE,
'/ops': DEFAULT_TEMPLATE,
'/op/[hash]': DEFAULT_TEMPLATE,
'/404': DEFAULT_TEMPLATE, '/404': DEFAULT_TEMPLATE,
'/name-domains': DEFAULT_TEMPLATE, '/name-domains': DEFAULT_TEMPLATE,
'/name-domains/[name]': DEFAULT_TEMPLATE, '/name-domains/[name]': DEFAULT_TEMPLATE,
......
...@@ -37,6 +37,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -37,6 +37,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/l2-withdrawals': 'withdrawals (L2 > L1)', '/l2-withdrawals': 'withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches', '/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%', '/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%',
'/ops': 'user operations',
'/op/[hash]': 'user operation %hash%',
'/404': 'error - page not found', '/404': 'error - page not found',
'/name-domains': 'domains search and resolve', '/name-domains': 'domains search and resolve',
'/name-domains/[name]': '%name% domain details', '/name-domains/[name]': '%name% domain details',
......
...@@ -37,6 +37,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -37,6 +37,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/l2-withdrawals': 'Withdrawals (L2 > L1)', '/l2-withdrawals': 'Withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches', '/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details', '/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details',
'/ops': 'User operations',
'/op/[hash]': 'User operation details',
'/404': '404', '/404': '404',
'/name-domains': 'Domains search and resolve', '/name-domains': 'Domains search and resolve',
'/name-domains/[name]': 'Domain details', '/name-domains/[name]': 'Domain details',
......
import type { SearchResultToken, SearchResultBlock, SearchResultAddressOrContract, SearchResultTx, SearchResultLabel, SearchResult } from 'types/api/search'; import type {
SearchResultToken,
SearchResultBlock,
SearchResultAddressOrContract,
SearchResultTx,
SearchResultLabel,
SearchResult,
SearchResultUserOp,
} from 'types/api/search';
export const token1: SearchResultToken = { export const token1: SearchResultToken = {
address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B', address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
...@@ -101,6 +109,13 @@ export const tx1: SearchResultTx = { ...@@ -101,6 +109,13 @@ export const tx1: SearchResultTx = {
url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd', url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd',
}; };
export const userOp1: SearchResultUserOp = {
timestamp: '2024-01-11T14:15:48.000000Z',
type: 'user_operation',
user_operation_hash: '0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
};
export const baseResponse: SearchResult = { export const baseResponse: SearchResult = {
items: [ items: [
token1, token1,
......
import type { UserOp } from 'types/api/userOps';
export const userOpData: UserOp = {
timestamp: '2024-01-19T12:42:12.000000Z',
transaction_hash: '0x715fe1474ac7bea3d6f4a03b1c5b6d626675fb0b103be29f849af65e9f1f9c6a',
user_logs_start_index: 40,
fee: '187125856691380',
call_gas_limit: '26624',
gas: '258875',
status: true,
aggregator_signature: null,
block_hash: '0xff5f41ec89e5fb3dfcf103bbbd67469fed491a7dd7cffdf00bd9e3bf45d8aeab',
pre_verification_gas: '48396',
factory: null,
signature: '0x2b95a173c1ea314d2c387e0d84194d221c14805e02157b7cefaf607a53e9081c0099ccbeaa1020ab91b862d4a4743dc1e20b4953f5bb6c13afeac760cef34fd11b',
verification_gas_limit: '61285',
max_fee_per_gas: '1575000898',
aggregator: null,
hash: '0xe72500491b3f2549ac53bd9de9dbb1d2edfc33cdddf5c079d6d64dfec650ef83',
gas_price: '1575000898',
user_logs_count: 1,
block_number: '10399597',
gas_used: '118810',
sender: {
ens_domain_name: null,
hash: '0xF0C14FF4404b188fAA39a3507B388998c10FE284',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
nonce: '0x000000000000000000000000000000000000000000000000000000000000004f',
entry_point: {
ens_domain_name: null,
hash: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
sponsor_type: 'paymaster_sponsor',
raw: {
// eslint-disable-next-line max-len
call_data: '0xb61d27f600000000000000000000000059f6aa952df7f048fd076e33e0ea8bb552d5ffd8000000000000000000000000000000000000000000000000003f3d017500800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000',
call_gas_limit: '26624',
init_code: '0x',
max_fee_per_gas: '1575000898',
max_priority_fee_per_gas: '1575000898',
nonce: '79',
// eslint-disable-next-line max-len
paymaster_and_data: '0x7cea357b5ac0639f89f9e378a1f03aa5005c0a250000000000000000000000000000000000000000000000000000000065b3a8800000000000000000000000000000000000000000000000000000000065aa6e0028fa4c57ac1141bc9ecd8c9243f618ade8ea1db10ab6c1d1798a222a824764ff2269a72ae7a3680fa8b03a80d8a00cdc710eaf37afdcc55f8c9c4defa3fdf2471b',
pre_verification_gas: '48396',
sender: '0xF0C14FF4404b188fAA39a3507B388998c10FE284',
signature: '0x2b95a173c1ea314d2c387e0d84194d221c14805e02157b7cefaf607a53e9081c0099ccbeaa1020ab91b862d4a4743dc1e20b4953f5bb6c13afeac760cef34fd11b',
verification_gas_limit: '61285',
},
max_priority_fee_per_gas: '1575000898',
revert_reason: null,
bundler: {
ens_domain_name: null,
hash: '0xd53Eb5203e367BbDD4f72338938224881Fc501Ab',
implementation_name: null,
is_contract: false,
is_verified: null,
name: null,
},
// eslint-disable-next-line max-len
call_data: '0xb61d27f600000000000000000000000059f6aa952df7f048fd076e33e0ea8bb552d5ffd8000000000000000000000000000000000000000000000000003f3d017500800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000',
paymaster: {
ens_domain_name: null,
hash: '0x7ceA357B5AC0639F89F9e378a1f03Aa5005C0a25',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
};
import type { UserOpsResponse } from 'types/api/userOps';
export const userOpsData: UserOpsResponse = {
items: [
{
address: {
ens_domain_name: null,
hash: '0xF0C14FF4404b188fAA39a3507B388998c10FE284',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
block_number: '10399597',
fee: '187125856691380',
hash: '0xe72500491b3f2549ac53bd9de9dbb1d2edfc33cdddf5c079d6d64dfec650ef83',
status: true,
timestamp: '2022-01-19T12:42:12.000000Z',
transaction_hash: '0x715fe1474ac7bea3d6f4a03b1c5b6d626675fb0b103be29f849af65e9f1f9c6a',
},
{
address:
{ ens_domain_name: null,
hash: '0x2c298CcaFFD1549e1C21F46966A6c236fCC66dB2',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
block_number: '10399596',
fee: '381895502291373',
hash: '0xcb945ae86608bdc88c3318245403c81a880fcb1e49fef18ac59477761c056cea',
status: false,
timestamp: '2022-01-19T12:42:00.000000Z',
transaction_hash: '0x558d699e7cbc235461d48ed04b8c3892d598a4000f20851760d00dc3513c2e48',
},
{
address: {
ens_domain_name: null,
hash: '0x2c298CcaFFD1549e1C21F46966A6c236fCC66dB2',
implementation_name: null,
is_contract: true,
is_verified: null,
name: null,
},
block_number: '10399560',
fee: '165019501210143',
hash: '0x84c1270b12af3f0ffa204071f1bf503ebf9b1ccf6310680383be5a2b6fd1d8e5',
status: true,
timestamp: '2022-01-19T12:32:00.000000Z',
transaction_hash: '0xc4c1c38680ec63139411aa2193275e8de44be15217c4256db9473bf0ea2b6264',
},
],
next_page_params: {
page_size: 50,
page_token: '10396582,0x9bf4d2a28813c5c244884cb20cdfe01dabb3f927234ae961eab6e38502de7a28',
},
};
...@@ -147,3 +147,13 @@ export const accounts: GetServerSideProps<Props> = async(context) => { ...@@ -147,3 +147,13 @@ export const accounts: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const userOps: GetServerSideProps<Props> = async(context) => {
if (!config.features.userOps.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
...@@ -39,6 +39,8 @@ declare module "nextjs-routes" { ...@@ -39,6 +39,8 @@ declare module "nextjs-routes" {
| StaticRoute<"/login"> | StaticRoute<"/login">
| DynamicRoute<"/name-domains/[name]", { "name": string }> | DynamicRoute<"/name-domains/[name]", { "name": string }>
| StaticRoute<"/name-domains"> | StaticRoute<"/name-domains">
| DynamicRoute<"/op/[hash]", { "hash": string }>
| StaticRoute<"/ops">
| StaticRoute<"/search-results"> | StaticRoute<"/search-results">
| StaticRoute<"/stats"> | StaticRoute<"/stats">
| DynamicRoute<"/token/[hash]", { "hash": string }> | DynamicRoute<"/token/[hash]", { "hash": string }>
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
const UserOp = dynamic(() => import('ui/pages/UserOp'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/op/[hash]" query={ props }>
<UserOp/>
</PageNextJs>
);
};
export default Page;
export { userOps as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const UserOps = dynamic(() => import('ui/pages/UserOps'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/ops">
<UserOps/>
</PageNextJs>
);
};
export default Page;
export { userOps as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -36,6 +36,9 @@ export const featureEnvs = { ...@@ -36,6 +36,9 @@ export const featureEnvs = {
{ name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' }, { name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' },
{ name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' }, { name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' },
], ],
userOps: [
{ name: 'NEXT_PUBLIC_HAS_USER_OPS', value: 'true' },
],
}; };
export const viewsEnvs = { export const viewsEnvs = {
......
...@@ -123,6 +123,8 @@ ...@@ -123,6 +123,8 @@
| "txn_batches" | "txn_batches"
| "unfinalized" | "unfinalized"
| "uniswap" | "uniswap"
| "user_op_slim"
| "user_op"
| "verified_token" | "verified_token"
| "verified" | "verified"
| "verify-contract" | "verify-contract"
......
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,
};
...@@ -15,7 +15,7 @@ export interface UserTags { ...@@ -15,7 +15,7 @@ export interface UserTags {
public_tags: Array<AddressTag> | null; public_tags: Array<AddressTag> | null;
} }
export interface AddressParam extends UserTags { export type AddressParamBasic = {
hash: string; hash: string;
implementation_name: string | null; implementation_name: string | null;
name: string | null; name: string | null;
...@@ -23,3 +23,5 @@ export interface AddressParam extends UserTags { ...@@ -23,3 +23,5 @@ export interface AddressParam extends UserTags {
is_verified: boolean | null; is_verified: boolean | null;
ens_domain_name: string | null; ens_domain_name: string | null;
} }
export type AddressParam = UserTags & AddressParamBasic;
...@@ -55,7 +55,14 @@ export interface SearchResultTx { ...@@ -55,7 +55,14 @@ export interface SearchResultTx {
url?: string; // not used by the frontend, we build the url ourselves url?: string; // not used by the frontend, we build the url ourselves
} }
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel; export interface SearchResultUserOp {
type: 'user_operation';
user_operation_hash: string;
timestamp: string;
url?: string; // not used by the frontend, we build the url ourselves
}
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp;
export interface SearchResult { export interface SearchResult {
items: Array<SearchResultItem>; items: Array<SearchResultItem>;
...@@ -79,5 +86,5 @@ export interface SearchResultFilters { ...@@ -79,5 +86,5 @@ export interface SearchResultFilters {
export interface SearchRedirectResult { export interface SearchRedirectResult {
parameter: string | null; parameter: string | null;
redirect: boolean; redirect: boolean;
type: 'address' | 'block' | 'transaction' | null; type: 'address' | 'block' | 'transaction' | 'user_operation' | null;
} }
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;
}
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;
...@@ -14,7 +14,6 @@ import config from 'configs/app'; ...@@ -14,7 +14,6 @@ import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import getBlockReward from 'lib/block/getBlockReward'; import getBlockReward from 'lib/block/getBlockReward';
import { GWEI, WEI, WEI_IN_GWEI, ZERO } from 'lib/consts'; import { GWEI, WEI, WEI_IN_GWEI, ZERO } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import { space } from 'lib/html-entities'; import { space } from 'lib/html-entities';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
...@@ -24,6 +23,7 @@ import CopyToClipboard from 'ui/shared/CopyToClipboard'; ...@@ -24,6 +23,7 @@ import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio'; import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
...@@ -176,14 +176,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -176,14 +176,7 @@ const BlockDetails = ({ query }: Props) => {
hint="Date & time at which block was produced." hint="Date & time at which block was produced."
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/> <DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>
{ dayjs(data.timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('llll') }
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
title="Transactions" title="Transactions"
......
...@@ -11,6 +11,7 @@ import useContractTabs from 'lib/hooks/useContractTabs'; ...@@ -11,6 +11,7 @@ import useContractTabs from 'lib/hooks/useContractTabs';
import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_INFO, ADDRESS_TABS_COUNTERS } from 'stubs/address'; import { ADDRESS_INFO, ADDRESS_TABS_COUNTERS } from 'stubs/address';
import { USER_OPS_ACCOUNT } from 'stubs/userOps';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated'; import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance'; import AddressCoinBalance from 'ui/address/AddressCoinBalance';
import AddressContract from 'ui/address/AddressContract'; import AddressContract from 'ui/address/AddressContract';
...@@ -20,6 +21,7 @@ import AddressLogs from 'ui/address/AddressLogs'; ...@@ -20,6 +21,7 @@ import AddressLogs from 'ui/address/AddressLogs';
import AddressTokens from 'ui/address/AddressTokens'; import AddressTokens from 'ui/address/AddressTokens';
import AddressTokenTransfers from 'ui/address/AddressTokenTransfers'; import AddressTokenTransfers from 'ui/address/AddressTokenTransfers';
import AddressTxs from 'ui/address/AddressTxs'; import AddressTxs from 'ui/address/AddressTxs';
import AddressUserOps from 'ui/address/AddressUserOps';
import AddressWithdrawals from 'ui/address/AddressWithdrawals'; import AddressWithdrawals from 'ui/address/AddressWithdrawals';
import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton'; import AddressFavoriteButton from 'ui/address/details/AddressFavoriteButton';
import AddressQrCode from 'ui/address/details/AddressQrCode'; import AddressQrCode from 'ui/address/details/AddressQrCode';
...@@ -62,6 +64,14 @@ const AddressPageContent = () => { ...@@ -62,6 +64,14 @@ const AddressPageContent = () => {
}, },
}); });
const userOpsAccountQuery = useApiQuery('user_ops_account', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: USER_OPS_ACCOUNT,
},
});
const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined); const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined);
const contractTabs = useContractTabs(addressQuery.data); const contractTabs = useContractTabs(addressQuery.data);
...@@ -74,6 +84,14 @@ const AddressPageContent = () => { ...@@ -74,6 +84,14 @@ const AddressPageContent = () => {
count: addressTabsCountersQuery.data?.transactions_count, count: addressTabsCountersQuery.data?.transactions_count,
component: <AddressTxs scrollRef={ tabsScrollRef }/>, component: <AddressTxs scrollRef={ tabsScrollRef }/>,
}, },
config.features.userOps.isEnabled && Boolean(userOpsAccountQuery.data?.total_ops) ?
{
id: 'user_ops',
title: 'User operations',
count: userOpsAccountQuery.data?.total_ops,
component: <AddressUserOps/>,
} :
undefined,
config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.withdrawals_count ? config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.withdrawals_count ?
{ {
id: 'withdrawals', id: 'withdrawals',
...@@ -140,7 +158,7 @@ const AddressPageContent = () => { ...@@ -140,7 +158,7 @@ const AddressPageContent = () => {
subTabs: contractTabs.map(tab => tab.id), subTabs: contractTabs.map(tab => tab.id),
} : undefined, } : undefined,
].filter(Boolean); ].filter(Boolean);
}, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data ]); }, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data ]);
const tags = ( const tags = (
<EntityTags <EntityTags
...@@ -151,6 +169,7 @@ const AddressPageContent = () => { ...@@ -151,6 +169,7 @@ const AddressPageContent = () => {
addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined, addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined,
addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined, addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined,
isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined, isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined,
userOpsAccountQuery.data ? { label: 'user_ops_acc', display_name: 'Smart contract wallet' } : undefined,
] } ] }
/> />
); );
...@@ -222,7 +241,10 @@ const AddressPageContent = () => { ...@@ -222,7 +241,10 @@ const AddressPageContent = () => {
<AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/> <AddressDetails addressQuery={ addressQuery } scrollRef={ tabsScrollRef }/>
{ /* should stay before tabs to scroll up with pagination */ } { /* should stay before tabs to scroll up with pagination */ }
<Box ref={ tabsScrollRef }></Box> <Box ref={ tabsScrollRef }></Box>
{ (addressQuery.isPlaceholderData || addressTabsCountersQuery.isPlaceholderData) ? <TabsSkeleton tabs={ tabs }/> : content } { (addressQuery.isPlaceholderData || addressTabsCountersQuery.isPlaceholderData || userOpsAccountQuery.isPlaceholderData) ?
<TabsSkeleton tabs={ tabs }/> :
content
}
</> </>
); );
}; };
......
...@@ -8,6 +8,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; ...@@ -8,6 +8,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app'; import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import SearchResults from './SearchResults'; import SearchResults from './SearchResults';
...@@ -157,6 +158,36 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -157,6 +158,36 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(component.locator('main')).toHaveScreenshot(); await expect(component.locator('main')).toHaveScreenshot();
}); });
const testWithUserOps = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.userOp1.user_operation_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.userOp1.user_operation_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.userOp1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
test.describe('with apps', () => { test.describe('with apps', () => {
const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || ''; const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || '';
const extendedTest = test.extend({ const extendedTest = test.extend({
......
...@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; ...@@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import React from 'react'; import React from 'react';
import config from 'configs/app';
import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps'; import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps';
import SearchResultListItem from 'ui/searchResults/SearchResultListItem'; import SearchResultListItem from 'ui/searchResults/SearchResultListItem';
import SearchResultsInput from 'ui/searchResults/SearchResultsInput'; import SearchResultsInput from 'ui/searchResults/SearchResultsInput';
...@@ -52,6 +53,12 @@ const SearchResultsPageContent = () => { ...@@ -52,6 +53,12 @@ const SearchResultsPageContent = () => {
router.replace({ pathname: '/tx/[hash]', query: { hash: redirectCheckQuery.data.parameter } }); router.replace({ pathname: '/tx/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return; return;
} }
case 'user_operation': {
if (config.features.userOps.isEnabled) {
router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return;
}
}
} }
} }
...@@ -62,12 +69,19 @@ const SearchResultsPageContent = () => { ...@@ -62,12 +69,19 @@ const SearchResultsPageContent = () => {
event.preventDefault(); event.preventDefault();
}, [ ]); }, [ ]);
const displayedItems = (data?.items || []).filter((item) => {
if (!config.features.userOps.isEnabled && item.type === 'user_operation') {
return false;
}
return true;
});
const content = (() => { const content = (() => {
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
const hasData = data?.items.length || (pagination.page === 1 && marketplaceApps.displayedApps.length); const hasData = displayedItems.length || (pagination.page === 1 && marketplaceApps.displayedApps.length);
if (!hasData) { if (!hasData) {
return null; return null;
...@@ -83,7 +97,7 @@ const SearchResultsPageContent = () => { ...@@ -83,7 +97,7 @@ const SearchResultsPageContent = () => {
searchTerm={ debouncedSearchTerm } searchTerm={ debouncedSearchTerm }
/> />
)) } )) }
{ data && data.items.map((item, index) => ( { displayedItems.map((item, index) => (
<SearchResultListItem <SearchResultListItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index } key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
data={ item } data={ item }
...@@ -110,7 +124,7 @@ const SearchResultsPageContent = () => { ...@@ -110,7 +124,7 @@ const SearchResultsPageContent = () => {
searchTerm={ debouncedSearchTerm } searchTerm={ debouncedSearchTerm }
/> />
)) } )) }
{ data && data.items.map((item, index) => ( { displayedItems.map((item, index) => (
<SearchResultTableItem <SearchResultTableItem
key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index } key={ (isPlaceholderData ? 'placeholder_' : 'actual_') + index }
data={ item } data={ item }
...@@ -130,7 +144,7 @@ const SearchResultsPageContent = () => { ...@@ -130,7 +144,7 @@ const SearchResultsPageContent = () => {
return null; return null;
} }
const resultsCount = pagination.page === 1 && !data?.next_page_params ? (data?.items.length || 0) + marketplaceApps.displayedApps.length : '50+'; const resultsCount = pagination.page === 1 && !data?.next_page_params ? (displayedItems.length || 0) + marketplaceApps.displayedApps.length : '50+';
const text = isPlaceholderData && pagination.page === 1 ? ( const text = isPlaceholderData && pagination.page === 1 ? (
<Skeleton h={ 6 } w="280px" borderRadius="full" mb={ pagination.isVisible ? 0 : 6 }/> <Skeleton h={ 6 } w="280px" borderRadius="full" mb={ pagination.isVisible ? 0 : 6 }/>
...@@ -141,7 +155,7 @@ const SearchResultsPageContent = () => { ...@@ -141,7 +155,7 @@ const SearchResultsPageContent = () => {
<chakra.span fontWeight={ 700 }> <chakra.span fontWeight={ 700 }>
{ resultsCount } { resultsCount }
</chakra.span> </chakra.span>
<span> matching result{ (((data?.items.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for </span> <span> matching result{ (((displayedItems.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for </span>
<chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span> <chakra.span fontWeight={ 700 }>{ debouncedSearchTerm }</chakra.span>
</Box> </Box>
) )
......
...@@ -22,6 +22,7 @@ import TxRawTrace from 'ui/tx/TxRawTrace'; ...@@ -22,6 +22,7 @@ import TxRawTrace from 'ui/tx/TxRawTrace';
import TxState from 'ui/tx/TxState'; import TxState from 'ui/tx/TxState';
import TxSubHeading from 'ui/tx/TxSubHeading'; import TxSubHeading from 'ui/tx/TxSubHeading';
import TxTokenTransfer from 'ui/tx/TxTokenTransfer'; import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import TxUserOps from 'ui/tx/TxUserOps';
const TransactionPageContent = () => { const TransactionPageContent = () => {
const router = useRouter(); const router = useRouter();
...@@ -47,6 +48,7 @@ const TransactionPageContent = () => { ...@@ -47,6 +48,7 @@ const TransactionPageContent = () => {
{ id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } : { id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } :
undefined, undefined,
{ id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer/> }, { id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer/> },
config.features.userOps.isEnabled ? { id: 'user_ops', title: 'User operations', component: <TxUserOps/> } : undefined,
{ id: 'internal', title: 'Internal txns', component: <TxInternals/> }, { id: 'internal', title: 'Internal txns', component: <TxInternals/> },
{ id: 'logs', title: 'Logs', component: <TxLogs/> }, { id: 'logs', title: 'Logs', component: <TxLogs/> },
{ id: 'state', title: 'State', component: <TxState/> }, { id: 'state', title: 'State', component: <TxState/> },
......
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 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 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: hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: USER_OP,
},
});
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 txHash={ userOpQuery.data?.transaction_hash } tokenTransferFilter={ filterTokenTransfersByLogIndex }/>,
},
{ id: 'logs', title: 'Logs', component: <TxLogs txHash={ userOpQuery.data?.transaction_hash } logsFilter={ filterLogsByLogIndex }/> },
{ id: 'raw', title: 'Raw', component: <UserOpRaw rawData={ userOpQuery.data?.raw } isLoading={ userOpQuery.isPlaceholderData }/> },
]), [ userOpQuery, filterTokenTransfersByLogIndex, filterLogsByLogIndex ]);
const tabIndex = useTabIndexFromQuery(tabs);
if (!hash) {
throw new Error('User operation not found', { cause: { status: 404 } });
}
if (userOpQuery.isError) {
throw new Error(undefined, { cause: userOpQuery.error });
}
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 ]);
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;
...@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal'; import LinkExternal from 'ui/shared/LinkExternal';
...@@ -56,7 +57,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -56,7 +57,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
wordBreak="break-all" wordBreak="break-all"
isLoading={ isLoading } isLoading={ isLoading }
onClick={ handleLinkClick } onClick={ handleLinkClick }
flexGrow={ 1 }
overflow="hidden" overflow="hidden"
> >
<Skeleton <Skeleton
...@@ -200,6 +200,26 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -200,6 +200,26 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
</TxEntity.Container> </TxEntity.Container>
); );
} }
case 'user_operation': {
return (
<UserOpEntity.Container>
<UserOpEntity.Icon/>
<UserOpEntity.Link
isLoading={ isLoading }
hash={ data.user_operation_hash }
onClick={ handleLinkClick }
>
<UserOpEntity.Content
asProp="mark"
hash={ data.user_operation_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</UserOpEntity.Link>
</UserOpEntity.Container>
);
}
} }
})(); })();
...@@ -240,6 +260,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -240,6 +260,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text> <Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
); );
} }
case 'user_operation': {
return (
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
);
}
case 'label': { case 'label': {
return ( return (
<Flex alignItems="center"> <Flex alignItems="center">
...@@ -295,12 +321,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -295,12 +321,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
return ( return (
<ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }> <ListItemMobile py={ 3 } fontSize="sm" rowGap={ 2 }>
<Flex justifyContent="space-between" w="100%" overflow="hidden" lineHeight={ 6 }> <Grid templateColumns="1fr auto" w="100%" overflow="hidden" lineHeight={ 6 }>
{ firstRow } { firstRow }
<Skeleton isLoaded={ !isLoading } color="text_secondary" ml={ 8 } textTransform="capitalize"> <Skeleton isLoaded={ !isLoading } color="text_secondary" ml={ 8 } textTransform="capitalize">
<span>{ category ? searchItemTitles[category].itemTitleShort : '' }</span> <span>{ category ? searchItemTitles[category].itemTitleShort : '' }</span>
</Skeleton> </Skeleton>
</Flex> </Grid>
{ Boolean(secondRow) && ( { Boolean(secondRow) && (
<Box w="100%" overflow="hidden" whiteSpace={ data.type !== 'app' ? 'nowrap' : undefined }> <Box w="100%" overflow="hidden" whiteSpace={ data.type !== 'app' ? 'nowrap' : undefined }>
{ secondRow } { secondRow }
......
...@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; ...@@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import LinkExternal from 'ui/shared/LinkExternal'; import LinkExternal from 'ui/shared/LinkExternal';
...@@ -284,6 +285,33 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -284,6 +285,33 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
</> </>
); );
} }
case 'user_operation': {
return (
<>
<Td colSpan={ 2 } fontSize="sm">
<UserOpEntity.Container>
<UserOpEntity.Icon/>
<UserOpEntity.Link
isLoading={ isLoading }
hash={ data.user_operation_hash }
onClick={ handleLinkClick }
>
<UserOpEntity.Content
asProp="mark"
hash={ data.user_operation_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</UserOpEntity.Link>
</UserOpEntity.Container>
</Td>
<Td fontSize="sm" verticalAlign="middle" isNumeric>
<Text variant="secondary">{ dayjs(data.timestamp).format('llll') }</Text>
</Td>
</>
);
}
} }
})(); })();
......
import { 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/>
<Skeleton isLoaded={ !isLoading } whiteSpace="normal">
{ dayjs(timestamp).format('llll') }
</Skeleton>
</>
);
};
export default DetailsTimestamp;
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 { SearchResultItem } from 'types/api/search'; import type { SearchResultItem } from 'types/api/search';
import type { MarketplaceAppOverview } from 'types/client/marketplace'; import type { MarketplaceAppOverview } from 'types/client/marketplace';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block'; import config from 'configs/app';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation';
export type Category = ApiCategory | 'app'; export type Category = ApiCategory | 'app';
export type ItemsCategoriesMap = export type ItemsCategoriesMap =
...@@ -23,6 +25,10 @@ export const searchCategories: Array<{id: Category; title: string }> = [ ...@@ -23,6 +25,10 @@ export const searchCategories: Array<{id: Category; title: string }> = [
{ id: 'block', title: 'Blocks' }, { id: 'block', title: 'Blocks' },
]; ];
if (config.features.userOps.isEnabled) {
searchCategories.push({ id: 'user_operation', title: 'User operations' });
}
export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleShort: string }> = { export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleShort: string }> = {
app: { itemTitle: 'App', itemTitleShort: 'App' }, app: { itemTitle: 'App', itemTitleShort: 'App' },
token: { itemTitle: 'Token', itemTitleShort: 'Token' }, token: { itemTitle: 'Token', itemTitleShort: 'Token' },
...@@ -31,6 +37,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh ...@@ -31,6 +37,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
public_tag: { itemTitle: 'Public tag', itemTitleShort: 'Tag' }, public_tag: { itemTitle: 'Public tag', itemTitleShort: 'Tag' },
transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' }, transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' },
block: { itemTitle: 'Block', itemTitleShort: 'Block' }, block: { itemTitle: 'Block', itemTitleShort: 'Block' },
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
}; };
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined { export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
...@@ -57,5 +64,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C ...@@ -57,5 +64,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'app': { case 'app': {
return 'app'; return 'app';
} }
case 'user_operation': {
return 'user_operation';
}
} }
} }
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;
...@@ -9,6 +9,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; ...@@ -9,6 +9,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import * as app from 'playwright/utils/app'; import * as app from 'playwright/utils/app';
import buildApiUrl from 'playwright/utils/buildApiUrl'; import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
...@@ -204,6 +205,35 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -204,6 +205,35 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
}); });
const testWithUserOps = base.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
});
testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: JSON.stringify(textAdMock.duck),
}));
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.tx1.tx_hash }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
searchMock.userOp1,
]),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search with view all link', async({ mount, page }) => { test('search with view all link', async({ mount, page }) => {
const API_URL = buildApiUrl('quick_search') + '?q=o'; const API_URL = buildApiUrl('quick_search') + '?q=o';
await page.route(API_URL, (route) => route.fulfill({ await page.route(API_URL, (route) => route.fulfill({
......
...@@ -111,12 +111,12 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props ...@@ -111,12 +111,12 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props
return <Text>Something went wrong. Try refreshing the page or come back later.</Text>; return <Text>Something went wrong. Try refreshing the page or come back later.</Text>;
} }
if (!query.data || query.data.length === 0) { const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
if (resultCategories.length === 0) {
return <Text>No results found.</Text>; return <Text>No results found.</Text>;
} }
const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]);
return ( return (
<> <>
{ resultCategories.length > 1 && ( { resultCategories.length > 1 && (
......
...@@ -12,6 +12,7 @@ import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; ...@@ -12,6 +12,7 @@ import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel'; import SearchBarSuggestLabel from './SearchBarSuggestLabel';
import SearchBarSuggestToken from './SearchBarSuggestToken'; import SearchBarSuggestToken from './SearchBarSuggestToken';
import SearchBarSuggestTx from './SearchBarSuggestTx'; import SearchBarSuggestTx from './SearchBarSuggestTx';
import SearchBarSuggestUserOp from './SearchBarSuggestUserOp';
interface Props { interface Props {
data: SearchResultItem; data: SearchResultItem;
...@@ -38,6 +39,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -38,6 +39,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'block': { case 'block': {
return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } }); return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } });
} }
case 'user_operation': {
return route({ pathname: '/op/[hash]', query: { hash: data.user_operation_hash } });
}
} }
})(); })();
...@@ -60,6 +64,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -60,6 +64,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'transaction': { case 'transaction': {
return <SearchBarSuggestTx data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>; return <SearchBarSuggestTx data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
} }
case 'user_operation': {
return <SearchBarSuggestUserOp data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
}
} }
})(); })();
......
import { chakra, Text, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultUserOp } from 'types/api/search';
import dayjs from 'lib/date/dayjs';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultUserOp;
isMobile: boolean | undefined;
searchTerm: string;
}
const SearchBarSuggestTx = ({ data, isMobile }: Props) => {
const icon = <UserOpEntity.Icon/>;
const hash = (
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
<HashStringShortenDynamic hash={ data.user_operation_hash } isTooltipDisabled/>
</chakra.mark>
);
const date = dayjs(data.timestamp).format('llll');
if (isMobile) {
return (
<>
<Flex alignItems="center">
{ icon }
{ hash }
</Flex>
<Text variant="secondary">{ date }</Text>
</>
);
}
return (
<Flex columnGap={ 2 }>
<Flex alignItems="center" minW={ 0 }>
{ icon }
{ hash }
</Flex>
<Text variant="secondary" textAlign="end" flexShrink={ 0 } ml="auto">{ date }</Text>
</Flex>
);
};
export default React.memo(SearchBarSuggestTx);
...@@ -22,7 +22,6 @@ import { route } from 'nextjs-routes'; ...@@ -22,7 +22,6 @@ import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import { WEI, WEI_IN_GWEI } from 'lib/consts'; import { WEI, WEI_IN_GWEI } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration'; import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
...@@ -34,6 +33,7 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -34,6 +33,7 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem'; import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ZkEvmBatchEntityL2 from 'ui/shared/entities/block/ZkEvmBatchEntityL2'; import ZkEvmBatchEntityL2 from 'ui/shared/entities/block/ZkEvmBatchEntityL2';
...@@ -226,10 +226,7 @@ const TxDetails = () => { ...@@ -226,10 +226,7 @@ const TxDetails = () => {
hint="Date & time of transaction inclusion, including length of time for confirmation" hint="Date & time of transaction inclusion, including length of time for confirmation"
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/> <DetailsTimestamp timestamp={ data.timestamp } 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"/> <TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isPlaceholderData } color="text_secondary"> <Skeleton isLoaded={ !isPlaceholderData } color="text_secondary">
<span>{ getConfirmationDuration(data.confirmation_duration) }</span> <span>{ getConfirmationDuration(data.confirmation_duration) }</span>
......
import { Box, Text } from '@chakra-ui/react'; import { Box, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { Log } from 'types/api/log';
import { SECOND } from 'lib/consts'; import { SECOND } from 'lib/consts';
import { LOG } from 'stubs/log'; import { LOG } from 'stubs/log';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
...@@ -13,8 +15,13 @@ import TxPendingAlert from 'ui/tx/TxPendingAlert'; ...@@ -13,8 +15,13 @@ import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo'; import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxLogs = () => { type Props = {
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND }); txHash?: string;
logsFilter?: (log: Log) => boolean;
}
const TxLogs = ({ txHash, logsFilter }: Props) => {
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND, txHash });
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({ const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_logs', resourceName: 'tx_logs',
pathParams: { hash: txInfo.data?.hash }, pathParams: { hash: txInfo.data?.hash },
...@@ -32,7 +39,17 @@ const TxLogs = () => { ...@@ -32,7 +39,17 @@ const TxLogs = () => {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
if (!data?.items.length) { let items: Array<Log> = [];
if (data?.items) {
if (isPlaceholderData) {
items = data?.items;
} else {
items = logsFilter ? data.items.filter(logsFilter) : data.items;
}
}
if (!items.length) {
return <Text as="span">There are no logs for this transaction.</Text>; return <Text as="span">There are no logs for this transaction.</Text>;
} }
...@@ -43,7 +60,7 @@ const TxLogs = () => { ...@@ -43,7 +60,7 @@ const TxLogs = () => {
<Pagination ml="auto" { ...pagination }/> <Pagination ml="auto" { ...pagination }/>
</ActionBar> </ActionBar>
) } ) }
{ data?.items.map((item, index) => <LogItem key={ index } { ...item } type="transaction" isLoading={ isPlaceholderData }/>) } { items.map((item, index) => <LogItem key={ index } { ...item } type="transaction" isLoading={ isPlaceholderData }/>) }
</Box> </Box>
); );
}; };
......
...@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; ...@@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { TokenType } from 'types/api/token'; import type { TokenType } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import { SECOND } from 'lib/consts'; import { SECOND } from 'lib/consts';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
...@@ -23,8 +24,13 @@ import useFetchTxInfo from 'ui/tx/useFetchTxInfo'; ...@@ -23,8 +24,13 @@ import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS); const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS);
const TxTokenTransfer = () => { type Props = {
const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND }); txHash?: string;
tokenTransferFilter?: (tt: TokenTransfer) => boolean;
}
const TxTokenTransfer = ({ txHash, tokenTransferFilter }: Props) => {
const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND, txHash });
const router = useRouter(); const router = useRouter();
...@@ -56,13 +62,23 @@ const TxTokenTransfer = () => { ...@@ -56,13 +62,23 @@ const TxTokenTransfer = () => {
const numActiveFilters = typeFilter.length; const numActiveFilters = typeFilter.length;
const isActionBarHidden = !numActiveFilters && !tokenTransferQuery.data?.items.length; const isActionBarHidden = !numActiveFilters && !tokenTransferQuery.data?.items.length;
let items: Array<TokenTransfer> = [];
if (tokenTransferQuery.data?.items) {
if (tokenTransferQuery.isPlaceholderData) {
items = tokenTransferQuery.data?.items;
} else {
items = tokenTransferFilter ? tokenTransferQuery.data.items.filter(tokenTransferFilter) : tokenTransferQuery.data.items;
}
}
const content = tokenTransferQuery.data?.items ? ( const content = tokenTransferQuery.data?.items ? (
<> <>
<Hide below="lg" ssr={ false }> <Hide below="lg" ssr={ false }>
<TokenTransferTable data={ tokenTransferQuery.data?.items } top={ isActionBarHidden ? 0 : 80 } isLoading={ tokenTransferQuery.isPlaceholderData }/> <TokenTransferTable data={ items } top={ isActionBarHidden ? 0 : 80 } isLoading={ tokenTransferQuery.isPlaceholderData }/>
</Hide> </Hide>
<Show below="lg" ssr={ false }> <Show below="lg" ssr={ false }>
<TokenTransferList data={ tokenTransferQuery.data?.items } isLoading={ tokenTransferQuery.isPlaceholderData }/> <TokenTransferList data={ items } isLoading={ tokenTransferQuery.isPlaceholderData }/>
</Show> </Show>
</> </>
) : null; ) : null;
...@@ -82,7 +98,7 @@ const TxTokenTransfer = () => { ...@@ -82,7 +98,7 @@ const TxTokenTransfer = () => {
return ( return (
<DataListDisplay <DataListDisplay
isError={ txsInfo.isError || tokenTransferQuery.isError } isError={ txsInfo.isError || tokenTransferQuery.isError }
items={ tokenTransferQuery.data?.items } items={ items }
emptyText="There are no token transfers." emptyText="There are no token transfers."
filterProps={{ filterProps={{
emptyFilteredText: `Couldn${ apos }t find any token transfer that matches your query.`, emptyFilteredText: `Couldn${ apos }t find any token transfer that matches your query.`,
......
import React from 'react';
import { SECOND } from 'lib/consts';
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 useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import UserOpsContent from 'ui/userOps/UserOpsContent';
const TxUserOps = () => {
const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const userOpsQuery = useQueryWithPages({
resourceName: 'user_ops',
options: {
enabled: !txsInfo.isPlaceholderData && Boolean(txsInfo.data?.status && txsInfo.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: txsInfo.data?.hash },
});
if (!txsInfo.isPending && !txsInfo.isPlaceholderData && !txsInfo.isError && !txsInfo.data.status) {
return txsInfo.socketStatus ? <TxSocketAlert status={ txsInfo.socketStatus }/> : <TxPendingAlert/>;
}
return <UserOpsContent query={ userOpsQuery } showTx={ false }/>;
};
export default TxUserOps;
...@@ -18,17 +18,18 @@ import { TX, TX_ZKEVM_L2 } from 'stubs/tx'; ...@@ -18,17 +18,18 @@ import { TX, TX_ZKEVM_L2 } from 'stubs/tx';
interface Params { interface Params {
onTxStatusUpdate?: () => void; onTxStatusUpdate?: () => void;
updateDelay?: number; updateDelay?: number;
txHash?: string;
} }
type ReturnType = UseQueryResult<Transaction, ResourceError<{ status: number }>> & { type ReturnType = UseQueryResult<Transaction, ResourceError<{ status: number }>> & {
socketStatus: 'close' | 'error' | undefined; socketStatus: 'close' | 'error' | undefined;
} }
export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay }: Params | undefined = {}): ReturnType { export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay, txHash }: Params | undefined = {}): ReturnType {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [ socketStatus, setSocketStatus ] = React.useState<'close' | 'error'>(); const [ socketStatus, setSocketStatus ] = React.useState<'close' | 'error'>();
const hash = getQueryParamString(router.query.hash); const hash = txHash || getQueryParamString(router.query.hash);
const queryResult = useApiQuery<'tx', { status: number }>('tx', { const queryResult = useApiQuery<'tx', { status: number }>('tx', {
pathParams: { hash }, pathParams: { hash },
......
This diff is collapsed.
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;
...@@ -9,18 +9,16 @@ import type { ZkEvmL2TxnBatch } from 'types/api/zkEvmL2TxnBatches'; ...@@ -9,18 +9,16 @@ import type { ZkEvmL2TxnBatch } from 'types/api/zkEvmL2TxnBatches';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import CopyToClipboard from 'ui/shared/CopyToClipboard'; import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import PrevNext from 'ui/shared/PrevNext'; import PrevNext from 'ui/shared/PrevNext';
import TextSeparator from 'ui/shared/TextSeparator';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
interface Props { interface Props {
...@@ -88,14 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => { ...@@ -88,14 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => {
title="Timestamp" title="Timestamp"
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/> <DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>
{ dayjs(data.timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('llll') }
</Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
<DetailsInfoItem <DetailsInfoItem
title="Verify tx hash" title="Verify tx hash"
......
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