diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index e33b62cc19..9bd4c2253c 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -20,6 +20,7 @@ export { default as sol2uml } from './sol2uml'; export { default as stats } from './stats'; export { default as suave } from './suave'; export { default as txInterpretation } from './txInterpretation'; -export { default as web3Wallet } from './web3Wallet'; +export { default as userOps } from './userOps'; export { default as verifiedTokens } from './verifiedTokens'; +export { default as web3Wallet } from './web3Wallet'; export { default as zkEvmRollup } from './zkEvmRollup'; diff --git a/configs/app/features/userOps.ts b/configs/app/features/userOps.ts new file mode 100644 index 0000000000..0e127f62f6 --- /dev/null +++ b/configs/app/features/userOps.ts @@ -0,0 +1,21 @@ +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; diff --git a/configs/envs/.env.eth_goerli b/configs/envs/.env.eth_goerli index d5a0d74f0d..0312652940 100644 --- a/configs/envs/.env.eth_goerli +++ b/configs/envs/.env.eth_goerli @@ -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_WEB3_WALLETS=['token_pocket','metamask'] NEXT_PUBLIC_VIEWS_CONTRACT_SOLIDITYSCAN_ENABLED='true' +NEXT_PUBLIC_HAS_USER_OPS='true' #meta NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth-goerli.png?raw=true diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 659383188e..82df0054bd 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -447,6 +447,7 @@ const schema = yup NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), NEXT_PUBLIC_OG_IMAGE_URL: yup.string().test(urlTest), NEXT_PUBLIC_IS_SUAVE_CHAIN: yup.boolean(), + NEXT_PUBLIC_HAS_USER_OPS: yup.boolean(), // 6. External services envs NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: yup.string(), diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index c56840103a..ed44cbd51b 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -73,6 +73,7 @@ frontend: NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS: "['fee_per_gas']" NEXT_PUBLIC_USE_NEXT_JS_PROXY: true NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]" + NEXT_PUBLIC_HAS_USER_OPS: true envFromSecret: NEXT_PUBLIC_SENTRY_DSN: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_SENTRY_DSN SENTRY_CSP_REPORT_URI: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/SENTRY_CSP_REPORT_URI diff --git a/docs/ENVS.md b/docs/ENVS.md index 9f25f99db5..6a7d14a2eb 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -33,6 +33,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will - [Banner ads](ENVS.md#banner-ads) - [Text ads](ENVS.md#text-ads) - [Beacon chain](ENVS.md#beacon-chain) + - [User operations](ENVS.md#user-operations-feature-erc-4337) - [Optimistic rollup (L2) chain](ENVS.md#optimistic-rollup-l2-chain) - [ZkEvm rollup (L2) chain](NVS.md#zkevm-rollup-l2-chain) - [Export data to CSV file](ENVS.md#export-data-to-csv-file) @@ -346,6 +347,14 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi   +### 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` | + +  + ### Optimistic rollup (L2) chain | Variable | Type| Description | Compulsoriness | Default value | Example value | diff --git a/icons/user_op.svg b/icons/user_op.svg new file mode 100644 index 0000000000..45f3f6d211 --- /dev/null +++ b/icons/user_op.svg @@ -0,0 +1,4 @@ + + + + diff --git a/icons/user_op_slim.svg b/icons/user_op_slim.svg new file mode 100644 index 0000000000..d8c64b52b8 --- /dev/null +++ b/icons/user_op_slim.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 5504d65c22..0c0c6055c7 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -77,6 +77,7 @@ import type { import type { TxInterpretationResponse } from 'types/api/txInterpretation'; import type { TTxsFilters } from 'types/api/txsFilters'; import type { TxStateChanges } from 'types/api/txStateChanges'; +import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps'; import type { VerifiedContractsSorting } from 'types/api/verifiedContracts'; import type { VisualizedContract } from 'types/api/visualization'; import type { WithdrawalsResponse, WithdrawalsCounters } from 'types/api/withdrawals'; @@ -579,6 +580,20 @@ export const RESOURCES = { filterFields: [], }, + // USER OPS + user_ops: { + path: '/api/v2/proxy/account-abstraction/operations', + filterFields: [ 'transaction_hash' as const, 'sender' as const ], + }, + user_op: { + path: '/api/v2/proxy/account-abstraction/operations/:hash', + pathParams: [ 'hash' as const ], + }, + user_ops_account: { + path: '/api/v2/proxy/account-abstraction/accounts/:hash', + pathParams: [ 'hash' as const ], + }, + // CONFIGS config_backend_version: { path: '/api/v2/config/backend-version', @@ -651,7 +666,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' | 'withdrawals' | 'address_withdrawals' | 'block_withdrawals' | 'watchlist' | 'private_tags_address' | 'private_tags_tx' | -'domains_lookup' | 'addresses_lookup'; +'domains_lookup' | 'addresses_lookup' | 'user_ops'; export type PaginatedResponse = ResourcePayload; @@ -754,6 +769,9 @@ Q extends 'addresses_lookup' ? EnsAddressLookupResponse : Q extends 'domain_info' ? EnsDomainDetailed : Q extends 'domain_events' ? EnsDomainEventsResponse : Q extends 'domains_lookup' ? EnsDomainLookupResponse : +Q extends 'user_ops' ? UserOpsResponse : +Q extends 'user_op' ? UserOp : +Q extends 'user_ops_account' ? UserOpsAccount : never; /* eslint-enable @typescript-eslint/indent */ @@ -775,6 +793,7 @@ Q extends 'tokens_bridged' ? TokensBridgedFilters : Q extends 'verified_contracts' ? VerifiedContractsFilters : Q extends 'addresses_lookup' ? EnsAddressLookupFilters : Q extends 'domains_lookup' ? EnsDomainLookupFilters : +Q extends 'user_ops' ? UserOpsFilters : never; /* eslint-enable @typescript-eslint/indent */ diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 0700fa40c5..db6978f7aa 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -46,6 +46,13 @@ export default function useNavItems(): ReturnType { icon: 'transactions', isActive: pathname === '/txs' || pathname === '/tx/[hash]', }; + const userOps: NavItem | null = config.features.userOps.isEnabled ? { + text: 'User operations', + nextRoute: { pathname: '/ops' as const }, + icon: 'user_op', + isActive: pathname === '/ops' || pathname === '/op/[hash]', + } : null; + const verifiedContracts: NavItem | null = { text: 'Verified contracts', @@ -64,6 +71,7 @@ export default function useNavItems(): ReturnType { blockchainNavItems = [ [ txs, + userOps, blocks, { text: 'Txn batches', @@ -71,7 +79,7 @@ export default function useNavItems(): ReturnType { icon: 'txn_batches', isActive: pathname === '/zkevm-l2-txn-batches' || pathname === '/zkevm-l2-txn-batch/[number]', }, - ], + ].filter(Boolean), [ topAccounts, verifiedContracts, @@ -95,6 +103,7 @@ export default function useNavItems(): ReturnType { { text: 'Output roots', nextRoute: { pathname: '/l2-output-roots' as const }, icon: 'output_roots', isActive: pathname === '/l2-output-roots' }, ], [ + userOps, topAccounts, verifiedContracts, ensLookup, @@ -103,6 +112,7 @@ export default function useNavItems(): ReturnType { } else { blockchainNavItems = [ txs, + userOps, blocks, topAccounts, verifiedContracts, diff --git a/lib/metadata/getPageOgType.ts b/lib/metadata/getPageOgType.ts index 53b7fbcb0f..9b8eab290b 100644 --- a/lib/metadata/getPageOgType.ts +++ b/lib/metadata/getPageOgType.ts @@ -39,6 +39,8 @@ const OG_TYPE_DICT: Record = { '/l2-withdrawals': 'Root page', '/zkevm-l2-txn-batches': 'Root page', '/zkevm-l2-txn-batch/[number]': 'Regular page', + '/ops': 'Root page', + '/op/[hash]': 'Regular page', '/404': 'Regular page', '/name-domains': 'Root page', '/name-domains/[name]': 'Regular page', diff --git a/lib/metadata/templates/description.ts b/lib/metadata/templates/description.ts index c46dd9a36b..089b9d5ab2 100644 --- a/lib/metadata/templates/description.ts +++ b/lib/metadata/templates/description.ts @@ -42,6 +42,8 @@ const TEMPLATE_MAP: Record = { '/l2-withdrawals': DEFAULT_TEMPLATE, '/zkevm-l2-txn-batches': DEFAULT_TEMPLATE, '/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE, + '/ops': DEFAULT_TEMPLATE, + '/op/[hash]': DEFAULT_TEMPLATE, '/404': DEFAULT_TEMPLATE, '/name-domains': DEFAULT_TEMPLATE, '/name-domains/[name]': DEFAULT_TEMPLATE, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index 542dfb7966..c597b92009 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -37,6 +37,8 @@ const TEMPLATE_MAP: Record = { '/l2-withdrawals': 'withdrawals (L2 > L1)', '/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches', '/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%', + '/ops': 'user operations', + '/op/[hash]': 'user operation %hash%', '/404': 'error - page not found', '/name-domains': 'domains search and resolve', '/name-domains/[name]': '%name% domain details', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index c9013c6360..f42ee545ad 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -37,6 +37,8 @@ export const PAGE_TYPE_DICT: Record = { '/l2-withdrawals': 'Withdrawals (L2 > L1)', '/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches', '/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details', + '/ops': 'User operations', + '/op/[hash]': 'User operation details', '/404': '404', '/name-domains': 'Domains search and resolve', '/name-domains/[name]': 'Domain details', diff --git a/mocks/search/index.ts b/mocks/search/index.ts index c12d54a28f..ef384d1d70 100644 --- a/mocks/search/index.ts +++ b/mocks/search/index.ts @@ -1,4 +1,12 @@ -import type { SearchResultToken, SearchResultBlock, SearchResultAddressOrContract, SearchResultTx, SearchResultLabel, SearchResult } from 'types/api/search'; +import type { + SearchResultToken, + SearchResultBlock, + SearchResultAddressOrContract, + SearchResultTx, + SearchResultLabel, + SearchResult, + SearchResultUserOp, +} from 'types/api/search'; export const token1: SearchResultToken = { address: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B', @@ -101,6 +109,13 @@ export const tx1: SearchResultTx = { url: '/tx/0x349d4025d03c6faec117ee10ac0bce7c7a805dd2cbff7a9f101304d9a8a525dd', }; +export const userOp1: SearchResultUserOp = { + timestamp: '2024-01-11T14:15:48.000000Z', + type: 'user_operation', + user_operation_hash: '0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61', + url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61', +}; + export const baseResponse: SearchResult = { items: [ token1, diff --git a/mocks/userOps/userOp.ts b/mocks/userOps/userOp.ts new file mode 100644 index 0000000000..efb7517187 --- /dev/null +++ b/mocks/userOps/userOp.ts @@ -0,0 +1,77 @@ +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, + }, +}; diff --git a/mocks/userOps/userOps.ts b/mocks/userOps/userOps.ts new file mode 100644 index 0000000000..a58ff6ed74 --- /dev/null +++ b/mocks/userOps/userOps.ts @@ -0,0 +1,58 @@ +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', + }, +}; diff --git a/nextjs/getServerSideProps.ts b/nextjs/getServerSideProps.ts index c0c1faaa20..5d0deebef1 100644 --- a/nextjs/getServerSideProps.ts +++ b/nextjs/getServerSideProps.ts @@ -147,3 +147,13 @@ export const accounts: GetServerSideProps = async(context) => { return base(context); }; + +export const userOps: GetServerSideProps = async(context) => { + if (!config.features.userOps.isEnabled) { + return { + notFound: true, + }; + } + + return base(context); +}; diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index 16aba3bf46..f29ed39d78 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -39,6 +39,8 @@ declare module "nextjs-routes" { | StaticRoute<"/login"> | DynamicRoute<"/name-domains/[name]", { "name": string }> | StaticRoute<"/name-domains"> + | DynamicRoute<"/op/[hash]", { "hash": string }> + | StaticRoute<"/ops"> | StaticRoute<"/search-results"> | StaticRoute<"/stats"> | DynamicRoute<"/token/[hash]", { "hash": string }> diff --git a/pages/op/[hash].tsx b/pages/op/[hash].tsx new file mode 100644 index 0000000000..63080f817c --- /dev/null +++ b/pages/op/[hash].tsx @@ -0,0 +1,20 @@ +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) => { + return ( + + + + ); +}; + +export default Page; + +export { userOps as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/pages/ops.tsx b/pages/ops.tsx new file mode 100644 index 0000000000..6d70165262 --- /dev/null +++ b/pages/ops.tsx @@ -0,0 +1,19 @@ +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 ( + + + + ); +}; + +export default Page; + +export { userOps as getServerSideProps } from 'nextjs/getServerSideProps'; diff --git a/playwright/utils/configs.ts b/playwright/utils/configs.ts index 4d9bc8802f..e9d35f9ec8 100644 --- a/playwright/utils/configs.ts +++ b/playwright/utils/configs.ts @@ -36,6 +36,9 @@ export const featureEnvs = { { name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' }, { name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' }, ], + userOps: [ + { name: 'NEXT_PUBLIC_HAS_USER_OPS', value: 'true' }, + ], }; export const viewsEnvs = { diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 09d8e061ce..e71c159e13 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -123,6 +123,8 @@ | "txn_batches" | "unfinalized" | "uniswap" + | "user_op_slim" + | "user_op" | "verified_token" | "verified" | "verify-contract" diff --git a/stubs/userOps.ts b/stubs/userOps.ts new file mode 100644 index 0000000000..3e2fcbd1c7 --- /dev/null +++ b/stubs/userOps.ts @@ -0,0 +1,66 @@ +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, +}; diff --git a/types/api/addressParams.ts b/types/api/addressParams.ts index e076d046cb..fe8cb3d90c 100644 --- a/types/api/addressParams.ts +++ b/types/api/addressParams.ts @@ -15,7 +15,7 @@ export interface UserTags { public_tags: Array | null; } -export interface AddressParam extends UserTags { +export type AddressParamBasic = { hash: string; implementation_name: string | null; name: string | null; @@ -23,3 +23,5 @@ export interface AddressParam extends UserTags { is_verified: boolean | null; ens_domain_name: string | null; } + +export type AddressParam = UserTags & AddressParamBasic; diff --git a/types/api/search.ts b/types/api/search.ts index 75fad2e780..49b1e87ec3 100644 --- a/types/api/search.ts +++ b/types/api/search.ts @@ -55,7 +55,14 @@ export interface SearchResultTx { url?: string; // not used by the frontend, we build the url ourselves } -export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel; +export interface SearchResultUserOp { + type: 'user_operation'; + user_operation_hash: string; + timestamp: string; + url?: string; // not used by the frontend, we build the url ourselves +} + +export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp; export interface SearchResult { items: Array; @@ -79,5 +86,5 @@ export interface SearchResultFilters { export interface SearchRedirectResult { parameter: string | null; redirect: boolean; - type: 'address' | 'block' | 'transaction' | null; + type: 'address' | 'block' | 'transaction' | 'user_operation' | null; } diff --git a/types/api/userOps.ts b/types/api/userOps.ts new file mode 100644 index 0000000000..86ea183a42 --- /dev/null +++ b/types/api/userOps.ts @@ -0,0 +1,75 @@ +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; + 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; +} diff --git a/ui/address/AddressUserOps.tsx b/ui/address/AddressUserOps.tsx new file mode 100644 index 0000000000..7c93c83b47 --- /dev/null +++ b/ui/address/AddressUserOps.tsx @@ -0,0 +1,35 @@ +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; +} + +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 ; +}; + +export default AddressUserOps; diff --git a/ui/block/BlockDetails.tsx b/ui/block/BlockDetails.tsx index 7f026778fb..a26ff383f3 100644 --- a/ui/block/BlockDetails.tsx +++ b/ui/block/BlockDetails.tsx @@ -14,7 +14,6 @@ import config from 'configs/app'; import type { ResourceError } from 'lib/api/resources'; import getBlockReward from 'lib/block/getBlockReward'; import { GWEI, WEI, WEI_IN_GWEI, ZERO } from 'lib/consts'; -import dayjs from 'lib/date/dayjs'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import { space } from 'lib/html-entities'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; @@ -24,6 +23,7 @@ import CopyToClipboard from 'ui/shared/CopyToClipboard'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; +import DetailsTimestamp from 'ui/shared/DetailsTimestamp'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import GasUsedToTargetRatio from 'ui/shared/GasUsedToTargetRatio'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; @@ -176,14 +176,7 @@ const BlockDetails = ({ query }: Props) => { hint="Date & time at which block was produced." isLoading={ isPlaceholderData } > - - - { dayjs(data.timestamp).fromNow() } - - - - { dayjs(data.timestamp).format('llll') } - + { }, }); + const userOpsAccountQuery = useApiQuery('user_ops_account', { + pathParams: { hash }, + queryOptions: { + enabled: Boolean(hash), + placeholderData: USER_OPS_ACCOUNT, + }, + }); + const isSafeAddress = useIsSafeAddress(!addressQuery.isPlaceholderData && addressQuery.data?.is_contract ? hash : undefined); const contractTabs = useContractTabs(addressQuery.data); @@ -74,6 +84,14 @@ const AddressPageContent = () => { count: addressTabsCountersQuery.data?.transactions_count, component: , }, + config.features.userOps.isEnabled && Boolean(userOpsAccountQuery.data?.total_ops) ? + { + id: 'user_ops', + title: 'User operations', + count: userOpsAccountQuery.data?.total_ops, + component: , + } : + undefined, config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.withdrawals_count ? { id: 'withdrawals', @@ -140,7 +158,7 @@ const AddressPageContent = () => { subTabs: contractTabs.map(tab => tab.id), } : undefined, ].filter(Boolean); - }, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data ]); + }, [ addressQuery.data, contractTabs, addressTabsCountersQuery.data, userOpsAccountQuery.data ]); const tags = ( { addressQuery.data?.implementation_address ? { label: 'proxy', display_name: 'Proxy' } : undefined, addressQuery.data?.token ? { label: 'token', display_name: 'Token' } : undefined, isSafeAddress ? { label: 'safe', display_name: 'Multisig: Safe' } : undefined, + userOpsAccountQuery.data ? { label: 'user_ops_acc', display_name: 'Smart contract wallet' } : undefined, ] } /> ); @@ -222,7 +241,10 @@ const AddressPageContent = () => { { /* should stay before tabs to scroll up with pagination */ } - { (addressQuery.isPlaceholderData || addressTabsCountersQuery.isPlaceholderData) ? : content } + { (addressQuery.isPlaceholderData || addressTabsCountersQuery.isPlaceholderData || userOpsAccountQuery.isPlaceholderData) ? + : + content + } ); }; diff --git a/ui/pages/SearchResults.pw.tsx b/ui/pages/SearchResults.pw.tsx index eaa8edf0e0..f66ed35bf2 100644 --- a/ui/pages/SearchResults.pw.tsx +++ b/ui/pages/SearchResults.pw.tsx @@ -8,6 +8,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import TestApp from 'playwright/TestApp'; import * as app from 'playwright/utils/app'; import buildApiUrl from 'playwright/utils/buildApiUrl'; +import * as configs from 'playwright/utils/configs'; import SearchResults from './SearchResults'; @@ -157,6 +158,36 @@ test('search by tx hash +@mobile', async({ mount, page }) => { await expect(component.locator('main')).toHaveScreenshot(); }); +const testWithUserOps = test.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: contextWithEnvs(configs.featureEnvs.userOps) as any, +}); + +testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => { + const hooksConfig = { + router: { + query: { q: searchMock.userOp1.user_operation_hash }, + }, + }; + await page.route(buildApiUrl('search') + `?q=${ searchMock.userOp1.user_operation_hash }`, (route) => route.fulfill({ + status: 200, + body: JSON.stringify({ + items: [ + searchMock.userOp1, + ], + }), + })); + + const component = await mount( + + + , + { hooksConfig }, + ); + + await expect(component.locator('main')).toHaveScreenshot(); +}); + test.describe('with apps', () => { const MARKETPLACE_CONFIG_URL = app.url + buildExternalAssetFilePath('NEXT_PUBLIC_MARKETPLACE_CONFIG_URL', 'https://marketplace-config.json') || ''; const extendedTest = test.extend({ diff --git a/ui/pages/SearchResults.tsx b/ui/pages/SearchResults.tsx index 51e86fde9a..872d20f607 100644 --- a/ui/pages/SearchResults.tsx +++ b/ui/pages/SearchResults.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import type { FormEvent } from 'react'; import React from 'react'; +import config from 'configs/app'; import useMarketplaceApps from 'ui/marketplace/useMarketplaceApps'; import SearchResultListItem from 'ui/searchResults/SearchResultListItem'; import SearchResultsInput from 'ui/searchResults/SearchResultsInput'; @@ -52,6 +53,12 @@ const SearchResultsPageContent = () => { router.replace({ pathname: '/tx/[hash]', query: { hash: redirectCheckQuery.data.parameter } }); return; } + case 'user_operation': { + if (config.features.userOps.isEnabled) { + router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } }); + return; + } + } } } @@ -62,12 +69,19 @@ const SearchResultsPageContent = () => { event.preventDefault(); }, [ ]); + const displayedItems = (data?.items || []).filter((item) => { + if (!config.features.userOps.isEnabled && item.type === 'user_operation') { + return false; + } + return true; + }); + const content = (() => { if (isError) { return ; } - const hasData = data?.items.length || (pagination.page === 1 && marketplaceApps.displayedApps.length); + const hasData = displayedItems.length || (pagination.page === 1 && marketplaceApps.displayedApps.length); if (!hasData) { return null; @@ -83,7 +97,7 @@ const SearchResultsPageContent = () => { searchTerm={ debouncedSearchTerm } /> )) } - { data && data.items.map((item, index) => ( + { displayedItems.map((item, index) => ( { searchTerm={ debouncedSearchTerm } /> )) } - { data && data.items.map((item, index) => ( + { displayedItems.map((item, index) => ( { 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 ? ( @@ -141,7 +155,7 @@ const SearchResultsPageContent = () => { { resultsCount } - matching result{ (((data?.items.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for + matching result{ (((displayedItems.length || 0) + marketplaceApps.displayedApps.length) > 1) || pagination.page > 1 ? 's' : '' } for { debouncedSearchTerm }” ) diff --git a/ui/pages/Transaction.tsx b/ui/pages/Transaction.tsx index 57328ecc58..a9cdc6819c 100644 --- a/ui/pages/Transaction.tsx +++ b/ui/pages/Transaction.tsx @@ -22,6 +22,7 @@ import TxRawTrace from 'ui/tx/TxRawTrace'; import TxState from 'ui/tx/TxState'; import TxSubHeading from 'ui/tx/TxSubHeading'; import TxTokenTransfer from 'ui/tx/TxTokenTransfer'; +import TxUserOps from 'ui/tx/TxUserOps'; const TransactionPageContent = () => { const router = useRouter(); @@ -47,6 +48,7 @@ const TransactionPageContent = () => { { id: 'wrapped', title: 'Regular tx details', component: } : undefined, { id: 'token_transfers', title: 'Token transfers', component: }, + config.features.userOps.isEnabled ? { id: 'user_ops', title: 'User operations', component: } : undefined, { id: 'internal', title: 'Internal txns', component: }, { id: 'logs', title: 'Logs', component: }, { id: 'state', title: 'State', component: }, diff --git a/ui/pages/UserOp.pw.tsx b/ui/pages/UserOp.pw.tsx new file mode 100644 index 0000000000..8b7b114c8c --- /dev/null +++ b/ui/pages/UserOp.pw.tsx @@ -0,0 +1,73 @@ +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( + + + , + { 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( + + + , + { hooksConfig: { + router: { + query: { hash: userOpData.hash }, + isReady: true, + }, + } }, + ); + + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/pages/UserOp.tsx b/ui/pages/UserOp.tsx new file mode 100644 index 0000000000..efa6c5cce5 --- /dev/null +++ b/ui/pages/UserOp.tsx @@ -0,0 +1,114 @@ +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 = React.useMemo(() => ([ + { id: 'index', title: 'Details', component: }, + { + id: 'token_transfers', + title: 'Token transfers', + component: , + }, + { id: 'logs', title: 'Logs', component: }, + { id: 'raw', title: 'Raw', component: }, + ]), [ 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 = ; + + return ( + <> + + + { userOpQuery.isPlaceholderData ? ( + <> + + { tabs[tabIndex]?.component } + + ) : + } + + ); +}; + +export default UserOp; diff --git a/ui/pages/UserOps.pw.tsx b/ui/pages/UserOps.pw.tsx new file mode 100644 index 0000000000..4d0cb8fe1d --- /dev/null +++ b/ui/pages/UserOps.pw.tsx @@ -0,0 +1,40 @@ +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( + + + + + , + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/pages/UserOps.tsx b/ui/pages/UserOps.tsx new file mode 100644 index 0000000000..189421d712 --- /dev/null +++ b/ui/pages/UserOps.tsx @@ -0,0 +1,28 @@ +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 ( + <> + + + + ); +}; + +export default UserOps; diff --git a/ui/pages/__screenshots__/SearchResults.pw.tsx_default_search-by-user-op-hash-mobile-1.png b/ui/pages/__screenshots__/SearchResults.pw.tsx_default_search-by-user-op-hash-mobile-1.png new file mode 100644 index 0000000000..a23bc41866 Binary files /dev/null and b/ui/pages/__screenshots__/SearchResults.pw.tsx_default_search-by-user-op-hash-mobile-1.png differ diff --git a/ui/pages/__screenshots__/SearchResults.pw.tsx_mobile_search-by-user-op-hash-mobile-1.png b/ui/pages/__screenshots__/SearchResults.pw.tsx_mobile_search-by-user-op-hash-mobile-1.png new file mode 100644 index 0000000000..bb081b0e6c Binary files /dev/null and b/ui/pages/__screenshots__/SearchResults.pw.tsx_mobile_search-by-user-op-hash-mobile-1.png differ diff --git a/ui/pages/__screenshots__/UserOp.pw.tsx_default_base-view-1.png b/ui/pages/__screenshots__/UserOp.pw.tsx_default_base-view-1.png new file mode 100644 index 0000000000..5dacaae56e Binary files /dev/null and b/ui/pages/__screenshots__/UserOp.pw.tsx_default_base-view-1.png differ diff --git a/ui/pages/__screenshots__/UserOp.pw.tsx_default_mobile-base-view-1.png b/ui/pages/__screenshots__/UserOp.pw.tsx_default_mobile-base-view-1.png new file mode 100644 index 0000000000..bf4653f170 Binary files /dev/null and b/ui/pages/__screenshots__/UserOp.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/pages/__screenshots__/UserOps.pw.tsx_default_base-view-mobile-1.png b/ui/pages/__screenshots__/UserOps.pw.tsx_default_base-view-mobile-1.png new file mode 100644 index 0000000000..9df2e3e44e Binary files /dev/null and b/ui/pages/__screenshots__/UserOps.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/UserOps.pw.tsx_mobile_base-view-mobile-1.png b/ui/pages/__screenshots__/UserOps.pw.tsx_mobile_base-view-mobile-1.png new file mode 100644 index 0000000000..8f1b4ce2d9 Binary files /dev/null and b/ui/pages/__screenshots__/UserOps.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_base-view-1.png b/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_base-view-1.png index c59f29f714..e4ffb78c1b 100644 Binary files a/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_base-view-1.png and b/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_base-view-1.png differ diff --git a/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_mobile-base-view-1.png b/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_mobile-base-view-1.png index b0fd12db39..980aef47d1 100644 Binary files a/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_mobile-base-view-1.png and b/ui/pages/__screenshots__/ZkEvmL2TxnBatch.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/searchResults/SearchResultListItem.tsx b/ui/searchResults/SearchResultListItem.tsx index f8375e87c1..743d378853 100644 --- a/ui/searchResults/SearchResultListItem.tsx +++ b/ui/searchResults/SearchResultListItem.tsx @@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; +import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import IconSvg from 'ui/shared/IconSvg'; import LinkExternal from 'ui/shared/LinkExternal'; @@ -56,7 +57,6 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { wordBreak="break-all" isLoading={ isLoading } onClick={ handleLinkClick } - flexGrow={ 1 } overflow="hidden" > { ); } + case 'user_operation': { + return ( + + + + + + + ); + } } })(); @@ -240,6 +260,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { { dayjs(data.timestamp).format('llll') } ); } + case 'user_operation': { + + return ( + { dayjs(data.timestamp).format('llll') } + ); + } case 'label': { return ( @@ -295,12 +321,12 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { return ( - + { firstRow } { category ? searchItemTitles[category].itemTitleShort : '' } - + { Boolean(secondRow) && ( { secondRow } diff --git a/ui/searchResults/SearchResultTableItem.tsx b/ui/searchResults/SearchResultTableItem.tsx index d2386943e3..6e3ea5140c 100644 --- a/ui/searchResults/SearchResultTableItem.tsx +++ b/ui/searchResults/SearchResultTableItem.tsx @@ -15,6 +15,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; import * as BlockEntity from 'ui/shared/entities/block/BlockEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; +import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; import IconSvg from 'ui/shared/IconSvg'; import LinkExternal from 'ui/shared/LinkExternal'; @@ -284,6 +285,33 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ); } + case 'user_operation': { + return ( + <> + + + + + + + + + + { dayjs(data.timestamp).format('llll') } + + + ); + } } })(); diff --git a/ui/shared/DetailsTimestamp.tsx b/ui/shared/DetailsTimestamp.tsx new file mode 100644 index 0000000000..7219c115d6 --- /dev/null +++ b/ui/shared/DetailsTimestamp.tsx @@ -0,0 +1,29 @@ +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 ( + <> + + + { dayjs(timestamp).fromNow() } + + + + { dayjs(timestamp).format('llll') } + + + ); +}; + +export default DetailsTimestamp; diff --git a/ui/shared/entities/userOp/UserOpEntity.pw.tsx b/ui/shared/entities/userOp/UserOpEntity.pw.tsx new file mode 100644 index 0000000000..c4a8faa3f0 --- /dev/null +++ b/ui/shared/entities/userOp/UserOpEntity.pw.tsx @@ -0,0 +1,72 @@ +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( + + + , + ); + + await expect(component).toHaveScreenshot(); + }); + }); +}); + +test('loading', async({ mount }) => { + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); +}); + +test('with copy +@dark-mode', async({ mount }) => { + const component = await mount( + + + , + ); + + await component.getByText(hash.slice(0, 4)).hover(); + + await expect(component).toHaveScreenshot(); +}); + +test('customization', async({ mount }) => { + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/shared/entities/userOp/UserOpEntity.tsx b/ui/shared/entities/userOp/UserOpEntity.tsx new file mode 100644 index 0000000000..c4a9022d31 --- /dev/null +++ b/ui/shared/entities/userOp/UserOpEntity.tsx @@ -0,0 +1,90 @@ +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; + +const Link = chakra((props: LinkProps) => { + const defaultHref = route({ pathname: '/op/[hash]', query: { hash: props.hash } }); + + return ( + + { props.children } + + ); +}); + +type IconProps = Omit & { + name?: EntityBase.IconBaseProps['name']; +}; + +const Icon = (props: IconProps) => { + return ( + + ); +}; + +type ContentProps = Omit & Pick; + +const Content = chakra((props: ContentProps) => { + return ( + + ); +}); + +type CopyProps = Omit & Pick; + +const Copy = (props: CopyProps) => { + return ( + + ); +}; + +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 ( + + + + + + + + ); +}; + +export default React.memo(chakra(UserOpEntity)); + +export { + Container, + Link, + Icon, + Content, + Copy, +}; diff --git a/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_dark-color-mode_with-copy-dark-mode-1.png b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_dark-color-mode_with-copy-dark-mode-1.png new file mode 100644 index 0000000000..062b6aff57 Binary files /dev/null and b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_dark-color-mode_with-copy-dark-mode-1.png differ diff --git a/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_customization-1.png b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_customization-1.png new file mode 100644 index 0000000000..fec63db3dc Binary files /dev/null and b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_customization-1.png differ diff --git a/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_icon-size-lg-1.png b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_icon-size-lg-1.png new file mode 100644 index 0000000000..dfe8149bbf Binary files /dev/null and b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_icon-size-lg-1.png differ diff --git a/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_icon-size-md-1.png b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_icon-size-md-1.png new file mode 100644 index 0000000000..4f855b2ff2 Binary files /dev/null and b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_icon-size-md-1.png differ diff --git a/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_loading-1.png b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_loading-1.png new file mode 100644 index 0000000000..67273105f8 Binary files /dev/null and b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_loading-1.png differ diff --git a/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_with-copy-dark-mode-1.png b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_with-copy-dark-mode-1.png new file mode 100644 index 0000000000..ae8e9976e1 Binary files /dev/null and b/ui/shared/entities/userOp/__screenshots__/UserOpEntity.pw.tsx_default_with-copy-dark-mode-1.png differ diff --git a/ui/shared/search/utils.ts b/ui/shared/search/utils.ts index dd900e60c3..652c973ebd 100644 --- a/ui/shared/search/utils.ts +++ b/ui/shared/search/utils.ts @@ -1,7 +1,9 @@ import type { SearchResultItem } from 'types/api/search'; import type { MarketplaceAppOverview } from 'types/client/marketplace'; -export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block'; +import config from 'configs/app'; + +export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation'; export type Category = ApiCategory | 'app'; export type ItemsCategoriesMap = @@ -23,6 +25,10 @@ export const searchCategories: Array<{id: Category; title: string }> = [ { id: 'block', title: 'Blocks' }, ]; +if (config.features.userOps.isEnabled) { + searchCategories.push({ id: 'user_operation', title: 'User operations' }); +} + export const searchItemTitles: Record = { app: { itemTitle: 'App', itemTitleShort: 'App' }, token: { itemTitle: 'Token', itemTitleShort: 'Token' }, @@ -31,6 +37,7 @@ export const searchItemTitles: Record { + 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 { text }; +}; + +export default UserOpSponsorType; diff --git a/ui/shared/userOps/UserOpStatus.tsx b/ui/shared/userOps/UserOpStatus.tsx new file mode 100644 index 0000000000..6c3a00d40b --- /dev/null +++ b/ui/shared/userOps/UserOpStatus.tsx @@ -0,0 +1,23 @@ +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 ( + + + + ); +}; + +export default UserOpStatus; diff --git a/ui/shared/userOps/UserOpsAddress.tsx b/ui/shared/userOps/UserOpsAddress.tsx new file mode 100644 index 0000000000..73036de756 --- /dev/null +++ b/ui/shared/userOps/UserOpsAddress.tsx @@ -0,0 +1,23 @@ +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 & { + address: string | AddressParamBasic; +} + +const UserOpsAddress = ({ address, ...props }: Props) => { + let addressParam; + if (typeof address === 'string') { + addressParam = { hash: address }; + } else { + addressParam = address; + } + + return ; +}; + +export default UserOpsAddress; diff --git a/ui/snippets/searchBar/SearchBar.pw.tsx b/ui/snippets/searchBar/SearchBar.pw.tsx index c855f37be0..36d78ba514 100644 --- a/ui/snippets/searchBar/SearchBar.pw.tsx +++ b/ui/snippets/searchBar/SearchBar.pw.tsx @@ -9,6 +9,7 @@ import contextWithEnvs from 'playwright/fixtures/contextWithEnvs'; import TestApp from 'playwright/TestApp'; import * as app from 'playwright/utils/app'; import buildApiUrl from 'playwright/utils/buildApiUrl'; +import * as configs from 'playwright/utils/configs'; import SearchBar from './SearchBar'; @@ -204,6 +205,35 @@ test('search by tx hash +@mobile', async({ mount, page }) => { await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); }); +const testWithUserOps = base.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: contextWithEnvs(configs.featureEnvs.userOps) as any, +}); + +testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => { + await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({ + status: 200, + body: JSON.stringify(textAdMock.duck), + })); + const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.tx1.tx_hash }`; + await page.route(API_URL, (route) => route.fulfill({ + status: 200, + body: JSON.stringify([ + searchMock.userOp1, + ]), + })); + + await mount( + + + , + ); + await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash); + await page.waitForResponse(API_URL); + + await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); +}); + test('search with view all link', async({ mount, page }) => { const API_URL = buildApiUrl('quick_search') + '?q=o'; await page.route(API_URL, (route) => route.fulfill({ diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggest.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggest.tsx index d7da026701..48f998ccd1 100644 --- a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggest.tsx +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggest.tsx @@ -111,12 +111,12 @@ const SearchBarSuggest = ({ query, searchTerm, onItemClick, containerId }: Props return Something went wrong. Try refreshing the page or come back later.; } - if (!query.data || query.data.length === 0) { + const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]); + + if (resultCategories.length === 0) { return No results found.; } - const resultCategories = searchCategories.filter(cat => itemsGroups[cat.id]); - return ( <> { resultCategories.length > 1 && ( diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx index 530980cdc2..e6dde430ab 100644 --- a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestItem.tsx @@ -12,6 +12,7 @@ import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; import SearchBarSuggestLabel from './SearchBarSuggestLabel'; import SearchBarSuggestToken from './SearchBarSuggestToken'; import SearchBarSuggestTx from './SearchBarSuggestTx'; +import SearchBarSuggestUserOp from './SearchBarSuggestUserOp'; interface Props { data: SearchResultItem; @@ -38,6 +39,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => case 'block': { return route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: String(data.block_hash) } }); } + case 'user_operation': { + return route({ pathname: '/op/[hash]', query: { hash: data.user_operation_hash } }); + } } })(); @@ -60,6 +64,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => case 'transaction': { return ; } + case 'user_operation': { + return ; + } } })(); diff --git a/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestUserOp.tsx b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestUserOp.tsx new file mode 100644 index 0000000000..21e0701822 --- /dev/null +++ b/ui/snippets/searchBar/SearchBarSuggest/SearchBarSuggestUserOp.tsx @@ -0,0 +1,48 @@ +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 = ; + const hash = ( + + + + ); + const date = dayjs(data.timestamp).format('llll'); + + if (isMobile) { + return ( + <> + + { icon } + { hash } + + { date } + + ); + } + + return ( + + + { icon } + { hash } + + { date } + + ); +}; + +export default React.memo(SearchBarSuggestTx); diff --git a/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_default_search-by-user-op-hash-mobile-1.png b/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_default_search-by-user-op-hash-mobile-1.png new file mode 100644 index 0000000000..73f86b73a5 Binary files /dev/null and b/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_default_search-by-user-op-hash-mobile-1.png differ diff --git a/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_mobile_search-by-user-op-hash-mobile-1.png b/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_mobile_search-by-user-op-hash-mobile-1.png new file mode 100644 index 0000000000..065edb5197 Binary files /dev/null and b/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_mobile_search-by-user-op-hash-mobile-1.png differ diff --git a/ui/tx/TxDetails.tsx b/ui/tx/TxDetails.tsx index 7a8e8afb89..582c0cb0ca 100644 --- a/ui/tx/TxDetails.tsx +++ b/ui/tx/TxDetails.tsx @@ -22,7 +22,6 @@ import { route } from 'nextjs-routes'; import config from 'configs/app'; import { WEI, WEI_IN_GWEI } from 'lib/consts'; -import dayjs from 'lib/date/dayjs'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle'; import getConfirmationDuration from 'lib/tx/getConfirmationDuration'; @@ -34,6 +33,7 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem'; +import DetailsTimestamp from 'ui/shared/DetailsTimestamp'; import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import ZkEvmBatchEntityL2 from 'ui/shared/entities/block/ZkEvmBatchEntityL2'; @@ -226,10 +226,7 @@ const TxDetails = () => { hint="Date & time of transaction inclusion, including length of time for confirmation" isLoading={ isPlaceholderData } > - - { dayjs(data.timestamp).fromNow() } - - { dayjs(data.timestamp).format('llll') } + { getConfirmationDuration(data.confirmation_duration) } diff --git a/ui/tx/TxLogs.tsx b/ui/tx/TxLogs.tsx index 68af313493..9136de2712 100644 --- a/ui/tx/TxLogs.tsx +++ b/ui/tx/TxLogs.tsx @@ -1,6 +1,8 @@ import { Box, Text } from '@chakra-ui/react'; import React from 'react'; +import type { Log } from 'types/api/log'; + import { SECOND } from 'lib/consts'; import { LOG } from 'stubs/log'; import { generateListStub } from 'stubs/utils'; @@ -13,8 +15,13 @@ import TxPendingAlert from 'ui/tx/TxPendingAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert'; import useFetchTxInfo from 'ui/tx/useFetchTxInfo'; -const TxLogs = () => { - const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND }); +type Props = { + txHash?: string; + logsFilter?: (log: Log) => boolean; +} + +const TxLogs = ({ txHash, logsFilter }: Props) => { + const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND, txHash }); const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({ resourceName: 'tx_logs', pathParams: { hash: txInfo.data?.hash }, @@ -32,7 +39,17 @@ const TxLogs = () => { return ; } - if (!data?.items.length) { + let items: Array = []; + + if (data?.items) { + if (isPlaceholderData) { + items = data?.items; + } else { + items = logsFilter ? data.items.filter(logsFilter) : data.items; + } + } + + if (!items.length) { return There are no logs for this transaction.; } @@ -43,7 +60,7 @@ const TxLogs = () => { ) } - { data?.items.map((item, index) => ) } + { items.map((item, index) => ) } ); }; diff --git a/ui/tx/TxTokenTransfer.tsx b/ui/tx/TxTokenTransfer.tsx index 834b601ccf..2f41299539 100644 --- a/ui/tx/TxTokenTransfer.tsx +++ b/ui/tx/TxTokenTransfer.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import React from 'react'; import type { TokenType } from 'types/api/token'; +import type { TokenTransfer } from 'types/api/tokenTransfer'; import { SECOND } from 'lib/consts'; import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery'; @@ -23,8 +24,13 @@ import useFetchTxInfo from 'ui/tx/useFetchTxInfo'; const getTokenFilterValue = (getFilterValuesFromQuery).bind(null, TOKEN_TYPE_IDS); -const TxTokenTransfer = () => { - const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND }); +type Props = { + txHash?: string; + tokenTransferFilter?: (tt: TokenTransfer) => boolean; +} + +const TxTokenTransfer = ({ txHash, tokenTransferFilter }: Props) => { + const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND, txHash }); const router = useRouter(); @@ -56,13 +62,23 @@ const TxTokenTransfer = () => { const numActiveFilters = typeFilter.length; const isActionBarHidden = !numActiveFilters && !tokenTransferQuery.data?.items.length; + let items: Array = []; + + 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 ? ( <> - + - + ) : null; @@ -82,7 +98,7 @@ const TxTokenTransfer = () => { return ( { + 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 ? : ; + } + + return ; +}; + +export default TxUserOps; diff --git a/ui/tx/useFetchTxInfo.tsx b/ui/tx/useFetchTxInfo.tsx index e83e9ff085..48df0383b0 100644 --- a/ui/tx/useFetchTxInfo.tsx +++ b/ui/tx/useFetchTxInfo.tsx @@ -18,17 +18,18 @@ import { TX, TX_ZKEVM_L2 } from 'stubs/tx'; interface Params { onTxStatusUpdate?: () => void; updateDelay?: number; + txHash?: string; } type ReturnType = UseQueryResult> & { 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 queryClient = useQueryClient(); 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', { pathParams: { hash }, diff --git a/ui/userOp/UserOpDetails.tsx b/ui/userOp/UserOpDetails.tsx new file mode 100644 index 0000000000..b067a0a3fb --- /dev/null +++ b/ui/userOp/UserOpDetails.tsx @@ -0,0 +1,309 @@ +import { Grid, GridItem, Text, Link, Skeleton } from '@chakra-ui/react'; +import type { UseQueryResult } from '@tanstack/react-query'; +import BigNumber from 'bignumber.js'; +import React from 'react'; +import { scroller, Element } from 'react-scroll'; + +import type { UserOp } from 'types/api/userOps'; + +import config from 'configs/app'; +import type { ResourceError } from 'lib/api/resources'; +import { WEI, WEI_IN_GWEI } from 'lib/consts'; +import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; +import { space } from 'lib/html-entities'; +import CurrencyValue from 'ui/shared/CurrencyValue'; +import DataFetchAlert from 'ui/shared/DataFetchAlert'; +import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; +import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; +import DetailsTimestamp from 'ui/shared/DetailsTimestamp'; +import BlockEntity from 'ui/shared/entities/block/BlockEntity'; +import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity'; +import RawInputData from 'ui/shared/RawInputData'; +import UserOpsAddress from 'ui/shared/userOps/UserOpsAddress'; +import UserOpSponsorType from 'ui/shared/userOps/UserOpSponsorType'; +import UserOpStatus from 'ui/shared/userOps/UserOpStatus'; +import Utilization from 'ui/shared/Utilization/Utilization'; + +interface Props { + query: UseQueryResult; +} + +const CUT_LINK_NAME = 'UserOpDetails__cutLink'; + +const UserOpDetails = ({ query }: Props) => { + const [ isExpanded, setIsExpanded ] = React.useState(false); + + const { data, isPlaceholderData, isError, error } = query; + + const handleCutClick = React.useCallback(() => { + setIsExpanded((flag) => !flag); + scroller.scrollTo(CUT_LINK_NAME, { + duration: 500, + smooth: true, + }); + }, []); + + if (isError) { + if (error?.status === 400 || error?.status === 404 || error?.status === 422) { + throwOnResourceLoadError({ isError, error }); + } + + return ; + } + + if (!data) { + return null; + } + + return ( + + + + + + + + + + + + + { data.revert_reason && ( + + + { data.revert_reason } + + + ) } + { data.timestamp && ( + + + + ) } + { !config.UI.views.tx.hiddenFields?.tx_fee && ( + + + + ) } + + + { BigNumber(data.gas).toFormat() } + + + + + { BigNumber(data.gas_used).toFormat() } + + + + + + + + + + + + + + { /* CUT */ } + + + + + { isExpanded ? 'Hide details' : 'View details' } + + + + + + { /* ADDITIONAL INFO */ } + { isExpanded && !isPlaceholderData && ( + <> + + + + { BigNumber(data.call_gas_limit).toFormat() } + + + { BigNumber(data.verification_gas_limit).toFormat() } + + + { BigNumber(data.pre_verification_gas).toFormat() } + + { !config.UI.views.tx.hiddenFields?.gas_fees && ( + <> + + { BigNumber(data.max_fee_per_gas).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } + + { space }({ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } Gwei) + + + { BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } + + { space }({ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } Gwei) + + + + ) } + + + + { data.aggregator && ( + + + + ) } + { data.aggregator_signature && ( + + { data.aggregator_signature } + + ) } + + + + { data.factory && ( + + + + ) } + { data.paymaster && ( + + + + ) } + + + + + + + + { data.signature } + + + { data.nonce } + + + + + + ) } + + ); +}; + +export default UserOpDetails; diff --git a/ui/userOp/UserOpRaw.tsx b/ui/userOp/UserOpRaw.tsx new file mode 100644 index 0000000000..66febf055d --- /dev/null +++ b/ui/userOp/UserOpRaw.tsx @@ -0,0 +1,30 @@ +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 = [ '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 ; +}; + +export default UserOpRaw; diff --git a/ui/userOps/UserOpsContent.tsx b/ui/userOps/UserOpsContent.tsx new file mode 100644 index 0000000000..5416dc3a35 --- /dev/null +++ b/ui/userOps/UserOpsContent.tsx @@ -0,0 +1,66 @@ +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 ; + } + + const content = query.data?.items ? ( + <> + + + + + { query.data.items.map((item, index) => ( + + )) } + + + ) : null; + + const actionBar = query.pagination.isVisible ? ( + + + + ) : null; + + return ( + + ); +}; + +export default UserOpsContent; diff --git a/ui/userOps/UserOpsListItem.tsx b/ui/userOps/UserOpsListItem.tsx new file mode 100644 index 0000000000..e3eb68b5bc --- /dev/null +++ b/ui/userOps/UserOpsListItem.tsx @@ -0,0 +1,95 @@ +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 ( + + + User op hash + + + + + Age + + { timeAgo } + + + Status + + + + + { showSender && ( + <> + Sender + + + + + ) } + + { showTx && ( + <> + Tx hash + + + + + ) } + + Block + + + + + { !config.UI.views.tx.hiddenFields?.tx_fee && ( + <> + Fee + + + + + + + ) } + + + ); +}; + +export default UserOpsListItem; diff --git a/ui/userOps/UserOpsTable.tsx b/ui/userOps/UserOpsTable.tsx new file mode 100644 index 0000000000..0478a283e4 --- /dev/null +++ b/ui/userOps/UserOpsTable.tsx @@ -0,0 +1,50 @@ +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; + isLoading?: boolean; + top: number; + showTx: boolean; + showSender: boolean; + }; + +const UserOpsTable = ({ items, isLoading, top, showTx, showSender }: Props) => { + return ( + + + + + + + { showSender && } + { showTx && } + + { !config.UI.views.tx.hiddenFields?.tx_fee && } + + + + { items.map((item, index) => { + return ( + + ); + }) } + +
User op hashAgeStatusSenderTx hashBlock{ `Fee ${ config.chain.currency.symbol }` }
+ ); +}; + +export default UserOpsTable; diff --git a/ui/userOps/UserOpsTableItem.tsx b/ui/userOps/UserOpsTableItem.tsx new file mode 100644 index 0000000000..cdd0a74609 --- /dev/null +++ b/ui/userOps/UserOpsTableItem.tsx @@ -0,0 +1,73 @@ +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 ( + + + + + + { timeAgo } + + + + + { showSender && ( + + + + ) } + { showTx && ( + + + + ) } + + + + { !config.UI.views.tx.hiddenFields?.tx_fee && ( + + + + ) } + + ); +}; + +export default UserOpsTableItem; diff --git a/ui/zkEvmL2TxnBatches/ZkEvmL2TxnBatchDetails.tsx b/ui/zkEvmL2TxnBatches/ZkEvmL2TxnBatchDetails.tsx index 359c092e77..d30951ba42 100644 --- a/ui/zkEvmL2TxnBatches/ZkEvmL2TxnBatchDetails.tsx +++ b/ui/zkEvmL2TxnBatches/ZkEvmL2TxnBatchDetails.tsx @@ -9,18 +9,16 @@ import type { ZkEvmL2TxnBatch } from 'types/api/zkEvmL2TxnBatches'; import { route } from 'nextjs-routes'; import type { ResourceError } from 'lib/api/resources'; -import dayjs from 'lib/date/dayjs'; import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError'; import CopyToClipboard from 'ui/shared/CopyToClipboard'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; +import DetailsTimestamp from 'ui/shared/DetailsTimestamp'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic'; -import IconSvg from 'ui/shared/IconSvg'; import LinkInternal from 'ui/shared/LinkInternal'; import PrevNext from 'ui/shared/PrevNext'; -import TextSeparator from 'ui/shared/TextSeparator'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; interface Props { @@ -88,14 +86,7 @@ const ZkEvmL2TxnBatchDetails = ({ query }: Props) => { title="Timestamp" isLoading={ isPlaceholderData } > - - - { dayjs(data.timestamp).fromNow() } - - - - { dayjs(data.timestamp).format('llll') } - +