Commit 6f8a9b8f authored by isstuev's avatar isstuev

user ops list and user op details

parent 652620a7
......@@ -20,6 +20,7 @@ export { default as sol2uml } from './sol2uml';
export { default as stats } from './stats';
export { default as suave } from './suave';
export { default as txInterpretation } from './txInterpretation';
export { default as web3Wallet } from './web3Wallet';
export { default as userOps } from './userOps';
export { default as verifiedTokens } from './verifiedTokens';
export { default as web3Wallet } from './web3Wallet';
export { default as zkEvmRollup } from './zkEvmRollup';
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const title = 'User operations';
const config: Feature<{ isEnabled: true }> = (() => {
if (getEnvValue('NEXT_PUBLIC_HAS_USER_OPS') === 'true') {
return Object.freeze({
title,
isEnabled: true,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
......@@ -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
......@@ -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)
- [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
&nbsp;
### User operationa chain
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_HAS_USER_OPS | `boolean` | Set to true to show user operations related data and pages | - | - | `true` |
&nbsp;
### Optimistic rollup (L2) chain
| Variable | Type| Description | Compulsoriness | Default value | Example value |
......
......@@ -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 } 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,17 @@ export const RESOURCES = {
filterFields: [],
},
// USER OPS
user_ops: {
path: '/api/v2/proxy/account-abstraction/operations',
filterFields: [],
},
user_op: {
path: '/api/v2/proxy/account-abstraction/operations/:hash',
pathParams: [ 'hash' as const ],
},
// CONFIGS
config_backend_version: {
path: '/api/v2/config/backend-version',
......@@ -651,7 +663,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' |
'withdrawals' | 'address_withdrawals' | 'block_withdrawals' |
'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup';
'domains_lookup' | 'addresses_lookup' | 'user_ops';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
......@@ -754,6 +766,8 @@ 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 :
never;
/* eslint-enable @typescript-eslint/indent */
......
......@@ -46,6 +46,14 @@ 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 },
// change!!!
icon: 'top-accounts',
isActive: pathname === '/ops' || pathname === '/op/[hash]',
} : null;
const verifiedContracts: NavItem | null =
{
text: 'Verified contracts',
......@@ -64,6 +72,7 @@ export default function useNavItems(): ReturnType {
blockchainNavItems = [
[
txs,
userOps,
blocks,
{
text: 'Txn batches',
......@@ -71,7 +80,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 +104,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 +113,7 @@ export default function useNavItems(): ReturnType {
} else {
blockchainNavItems = [
txs,
userOps,
blocks,
topAccounts,
verifiedContracts,
......
......@@ -39,6 +39,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/l2-withdrawals': 'Root page',
'/zkevm-l2-txn-batches': 'Root page',
'/zkevm-l2-txn-batch/[number]': 'Regular page',
'/ops': 'Root page',
'/op/[hash]': 'Regular page',
'/404': 'Regular page',
'/name-domains': 'Root page',
'/name-domains/[name]': 'Regular page',
......
......@@ -42,6 +42,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/l2-withdrawals': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batches': DEFAULT_TEMPLATE,
'/zkevm-l2-txn-batch/[number]': DEFAULT_TEMPLATE,
'/ops': DEFAULT_TEMPLATE,
'/op/[hash]': DEFAULT_TEMPLATE,
'/404': DEFAULT_TEMPLATE,
'/name-domains': DEFAULT_TEMPLATE,
'/name-domains/[name]': DEFAULT_TEMPLATE,
......
......@@ -37,6 +37,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/l2-withdrawals': 'withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'zkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'zkEvm L2 Tx batch %number%',
'/ops': 'user operations',
'/op/[hash]': 'user operation %hash%',
'/404': 'error - page not found',
'/name-domains': 'domains search and resolve',
'/name-domains/[name]': '%name% domain details',
......
......@@ -37,6 +37,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/l2-withdrawals': 'Withdrawals (L2 > L1)',
'/zkevm-l2-txn-batches': 'ZkEvm L2 Tx batches',
'/zkevm-l2-txn-batch/[number]': 'ZkEvm L2 Tx batch details',
'/ops': 'User operations',
'/op/[hash]': 'User operation details',
'/404': '404',
'/name-domains': 'Domains search and resolve',
'/name-domains/[name]': 'Domain details',
......
......@@ -147,3 +147,13 @@ export const accounts: GetServerSideProps<Props> = async(context) => {
return base(context);
};
export const userOps: GetServerSideProps<Props> = async(context) => {
if (!config.features.userOps.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
......@@ -39,6 +39,8 @@ declare module "nextjs-routes" {
| StaticRoute<"/login">
| DynamicRoute<"/name-domains/[name]", { "name": string }>
| StaticRoute<"/name-domains">
| DynamicRoute<"/op/[hash]", { "hash": string }>
| StaticRoute<"/ops">
| StaticRoute<"/search-results">
| StaticRoute<"/stats">
| DynamicRoute<"/token/[hash]", { "hash": string }>
......
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="/block/[height_or_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';
import type { UserOpsItem, UserOp } from 'types/api/userOps';
export const USER_OPS_ITEM: UserOpsItem = {
hash: '0xb94fab8f31f83001a23e20b2ce3cdcfb284c57a64b9a073e0e09c018bc701978',
block_number: '10356381',
transaction_hash: '0xffcef406eb73986e25666ecfbe03b9dd19d19f28af7477923a5d2979f7b06a43',
address: '0x749abd4A31CC4B005526A5F288BEB27f3e239067',
timestamp: '1704964728',
status: true,
fee: '48285720012071430',
};
export const USER_OP: UserOp = {
hash: '0x20d6ed2bf0a04b011184c801e0b79fbd9411d32be14a6fab3d6150f2691970df',
sender: '0xAb28462026f7E7318808a6aF1accAbD13031Af9c',
nonce: '0x000000000000000000000000000000000000000000000000000000000000000b',
init_code: null,
// eslint-disable-next-line max-len
call_data: '0x51945447000000000000000000000000fd04fb0538479ad70dfae539c875b2c1802050120000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024d55f960d0adbe9c9b444dc1fbe2b475312067d9dea42db93646ccc87057657aba1d49cd800000000000000000000000000000000000000000000000000000000',
call_gas_limit: '71316',
verification_gas_limit: '91551',
pre_verification_gas: '53627',
max_fee_per_gas: '100000020',
max_priority_fee_per_gas: '100000000',
// eslint-disable-next-line max-len
signature: '0x00000000e1dcf07c8718b7332ec4df784a18ea1d94a22886b9640c47a14ff3642c11840a63b7bb7f1d421d3eed4f8c5ca40cc421bbde196afa430aad9773703e23c382d11c',
aggregator: null,
aggregator_signature: null,
entry_point: '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789',
transaction_hash: '0xf2609117783dde161ee08f48e0ce4769645956eb7b86897290592cf85a268d7d',
block_number: '10358181',
block_hash: '0xbb29763848c5201c47c3a0d44148b662222c480c4f12ec03fe7f8129d6af9eb0',
bundler: '0x6892BEF4aE1b5cb33F9A175Ab822518c9025fc3C',
factory: null,
paymaster: '0xE93ECa6595fe94091DC1af46aaC2A8b5D7990770',
status: true,
revert_reason: null,
gas: '399596',
sponsor_type: 'paymaster_sponsor',
fee: '17927001792700',
timestamp: '1704994440',
};
import type { AddressParam } from './addressParams';
export type UserOpsItem = {
hash: string;
block_number: string;
transaction_hash: string;
address: string | AddressParam;
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 | AddressParam;
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;
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;
factory: string | null;
paymaster: string | null;
sponsor_type: UserOpSponsorType;
init_code: string | null;
signature: string;
nonce: string;
call_data: string;
}
......@@ -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 }
>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>
{ dayjs(data.timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('llll') }
</Skeleton>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Transactions"
......
import { useRouter } from 'next/router';
import React from 'react';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { USER_OP } from 'stubs/userOps';
import TextAd from 'ui/shared/ad/TextAd';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import UserOpDetails from 'ui/userOp/UserOpDetails';
const TAB_LIST_PROPS = {
marginBottom: 0,
py: 5,
marginTop: -5,
};
const BlockPageContent = () => {
const router = useRouter();
const isMobile = useIsMobile();
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 tabs: Array<RoutedTab> = React.useMemo(() => ([
{ id: 'index', title: 'Details', component: <UserOpDetails query={ userOpQuery }/> },
{ id: 'token_transfers', title: 'Token transfers', component: null },
// { id: 'token_transfers', title: 'Token transfers', component: <UserOpTokenTransfers txHash={ userOpQuery.data?.transaction_hash }/> },
// { id: 'call_data', title: 'Call data', component: <UserOpCallData callData={ userOpQuery.data?.call_data }/> }
// { id: 'logs', title: 'Logs', component: <UserOpLogs txHash={ userOpQuery.data?.transaction_hash }/> }
// { id: 'raw', title: 'Raw', component: <UserOpRaw txHash={ userOpQuery.data?.transaction_hash }/> }
].filter(Boolean)), [ userOpQuery ]);
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 = <HashStringShortenDynamic hash={ hash }/>;
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title="User operation details"
backLink={ backLink }
secondRow={ titleSecondRow }
/>
{ userOpQuery.isPlaceholderData ? <TabsSkeleton tabs={ tabs }/> : <RoutedTabs tabs={ tabs } tabListProps={ isMobile ? undefined : TAB_LIST_PROPS }/> }
</>
);
};
export default BlockPageContent;
import { Hide, Show } from '@chakra-ui/react';
import React from 'react';
import { USER_OPS_ITEM } from 'stubs/userOps';
import { generateListStub } from 'stubs/utils';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import UserOpsListItem from 'ui/userOps/UserOpsListItem';
import UserOpsTable from 'ui/userOps/UserOpsTable';
const UserOps = () => {
const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({
resourceName: 'user_ops',
options: {
placeholderData: generateListStub<'user_ops'>(USER_OPS_ITEM, 50, { next_page_params: {
page_token: '10355938,0x5956a847d8089e254e02e5111cad6992b99ceb9e5c2dc4343fd53002834c4dc6',
page_size: 50,
} }),
},
});
const content = data?.items ? (
<>
<Show below="lg" ssr={ false }>
{ data.items.map(((item, index) => (
<UserOpsListItem
key={ item.hash + (isPlaceholderData ? String(index) : '') }
item={ item }
isLoading={ isPlaceholderData }
/>
))) }
</Show>
<Hide below="lg" ssr={ false }>
<UserOpsTable items={ data.items } top={ pagination.isVisible ? 80 : 0 } isLoading={ isPlaceholderData }/>
</Hide>
</>
) : null;
const actionBar = pagination.isVisible ? (
<ActionBar mt={ -6 } alignItems="center">
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) : null;
return (
<>
<PageTitle title="User operations" withTextAd/>
<DataListDisplay
isError={ isError }
items={ data?.items }
emptyText="There are no user operations."
content={ content }
actionBar={ actionBar }
/>
</>
);
};
export default UserOps;
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={ 1 }>
{ 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>
// <TxEntity
// 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 ?? 'transactions_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 { 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 { AddressParam } from 'types/api/addressParams';
import AddressEntity from '../entities/address/AddressEntity';
import type { EntityProps } from '../entities/address/AddressEntity';
type Props = Omit<EntityProps, 'address'> & {
address: string | AddressParam;
}
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;
......@@ -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 }
>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 2 }>{ dayjs(data.timestamp).fromNow() }</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">{ dayjs(data.timestamp).format('llll') }</Skeleton>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isPlaceholderData } color="text_secondary">
<span>{ getConfirmationDuration(data.confirmation_duration) }</span>
......
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 { 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 UserOpsAddress from 'ui/shared/userOps/UserOpsAddress';
import UserOpSponsorType from 'ui/shared/userOps/UserOpSponsorType';
import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
interface Props {
query: UseQueryResult<UserOp, ResourceError>;
}
const CUT_LINK_NAME = 'UserOpDetails__cutLink';
const UserOpDetails = ({ query }: Props) => {
const [ isExpanded, setIsExpanded ] = React.useState(false);
const { data, isPlaceholderData, isError, error } = query;
const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag);
scroller.scrollTo(CUT_LINK_NAME, {
duration: 500,
smooth: true,
});
}, []);
if (isError) {
if (error?.status === 400 || error?.status === 404 || error?.status === 422) {
throwOnResourceLoadError({ isError, error });
}
return <DataFetchAlert/>;
}
if (!data) {
return null;
}
return (
<Grid
columnGap={ 8 }
rowGap={{ base: 3, lg: 3 }}
templateColumns={{ base: 'minmax(0, 1fr)', lg: 'minmax(min-content, 220px) minmax(0, 1fr)' }}
overflow="hidden"
>
<DetailsInfoItem
title="User operation hash"
hint="Unique character string assigned to every User operation"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>
{ data.hash }
</Skeleton>
</DetailsInfoItem>
<DetailsInfoItem
title="Sender"
hint="The address of the smart contract account"
isLoading={ isPlaceholderData }
>
<UserOpsAddress address={ data.sender } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Status"
hint="Current User operation state"
isLoading={ isPlaceholderData }
>
<UserOpStatus status={ data.status } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
{ data.revert_reason && (
<DetailsInfoItem
title="Revert reason"
hint="The revert reason of the User operation"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>
{ data.revert_reason }
</Skeleton>
</DetailsInfoItem>
) }
{ data.timestamp && (
<DetailsInfoItem
title="Timestamp"
hint="Date and time of User operation"
isLoading={ isPlaceholderData }
>
{ /* timestamp format will be fixed */ }
<DetailsTimestamp timestamp={ Number(data.timestamp) * 1000 } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
) }
{ /* condition? */ }
<DetailsInfoItem
title="Fee"
hint="Total User operation fee"
isLoading={ isPlaceholderData }
>
<CurrencyValue
value={ data.fee }
currency={ config.chain.currency.symbol }
isLoading={ isPlaceholderData }
/>
</DetailsInfoItem>
<DetailsInfoItem
title="Gas limit"
hint="Gas limit for the User operation"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>
{ BigNumber(data.gas).toFormat() }
</Skeleton>
</DetailsInfoItem>
<DetailsInfoItem
title="Transaction hash"
hint="Hash of the transaction this User operation belongs to"
>
<TxEntity hash={ data.transaction_hash } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Block"
hint="Block number containing this User operation"
>
<BlockEntity number={ data.block_number } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Entry point"
hint="Contract that executes bundles of User operations"
>
<UserOpsAddress address={ data.entry_point } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
{ /* CUT */ }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Element name={ CUT_LINK_NAME }>
<Skeleton isLoaded={ !isPlaceholderData } mt={ 6 } display="inline-block">
<Link
fontSize="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
onClick={ handleCutClick }
>
{ isExpanded ? 'Hide details' : 'View details' }
</Link>
</Skeleton>
</Element>
</GridItem>
{ /* ADDITIONAL INFO */ }
{ isExpanded && !isPlaceholderData && (
<>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
<DetailsInfoItem
title="Call gas limit"
hint="Gas limit for execution phase"
>
{ BigNumber(data.call_gas_limit).toFormat() }
</DetailsInfoItem>
<DetailsInfoItem
title="Verification gas limit"
hint="Gas limit for verification phase"
>
{ BigNumber(data.verification_gas_limit).toFormat() }
</DetailsInfoItem>
<DetailsInfoItem
title="Pre-verification gas"
hint="Gas to compensate the bundler"
>
{ BigNumber(data.pre_verification_gas).toFormat() }
</DetailsInfoItem>
<DetailsInfoItem
title="Max fee per gas"
hint="Maximum fee per gas "
>
<Text>{ BigNumber(data.max_fee_per_gas).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } Gwei)
</Text>
</DetailsInfoItem>
<DetailsInfoItem
title="Max priority fee per gas"
hint="Maximum priority fee per gas"
>
<Text>{ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } Gwei)
</Text>
</DetailsInfoItem>
<DetailsInfoItemDivider/>
{ data.aggregator && (
<DetailsInfoItem
title="Aggregator"
hint="Helper contract to validate an aggregated signature"
>
<UserOpsAddress address={ data.aggregator }/>
</DetailsInfoItem>
) }
{ data.aggregator_signature && (
<DetailsInfoItem
title="Aggregator signature"
hint="Aggregator signature"
>
{ data.aggregator_signature }
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Bundler"
hint="A node (block builder) that handles User operations"
>
<UserOpsAddress address={ data.bundler }/>
</DetailsInfoItem>
{ data.factory && (
<DetailsInfoItem
title="Factory"
hint="Smart contract that deploys new smart contract wallets for users"
>
<UserOpsAddress address={ data.factory }/>
</DetailsInfoItem>
) }
{ data.paymaster && (
<DetailsInfoItem
title="Paymaster"
hint="Contract to sponsor the gas fees for User operations"
>
<UserOpsAddress address={ data.paymaster }/>
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Sponsor type"
hint="Type of the gas fees sponsor"
>
<UserOpSponsorType sponsorType={ data.sponsor_type }/>
</DetailsInfoItem>
<DetailsInfoItemDivider/>
{ data.init_code && (
<DetailsInfoItem
title="Init code"
hint="Code used to deploy the account if not yet on-chain"
>
{ data.init_code }
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Signature"
hint="Used to validate a User operation along with the nonce during verification"
wordBreak="break-all"
whiteSpace="normal"
>
{ data.signature }
</DetailsInfoItem>
<DetailsInfoItem
title="Nonce"
hint="Anti-replay protection; also used as the salt for first-time account creation"
>
{ data.nonce }
</DetailsInfoItem>
</>
) }
</Grid>
);
};
export default UserOpDetails;
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;
};
const UserOpsListItem = ({ item, isLoading }: Props) => {
// format will be fixed on the back-end
const timeAgo = dayjs(Number(item.timestamp) * 1000).fromNow();
return (
<ListItemMobileGrid.Container gridTemplateColumns="100px auto">
<ListItemMobileGrid.Label isLoading={ isLoading }>User op hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<UserOpEntity hash={ item.hash } noCopy={ false } isLoading={ isLoading }/>
</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>
<ListItemMobileGrid.Label isLoading={ isLoading }>Sender</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<UserOpsAddress
address={ item.address }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Tx hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TxEntity
hash={ item.transaction_hash }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Block</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlockEntity
number={ item.block_number }
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
/>
</ListItemMobileGrid.Value>
<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;
};
const UserOpsTable = ({ items, isLoading, top }: 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>
<Th w="160px">Sender</Th>
<Th w="160px">Tx hash</Th>
<Th w="40%">Block</Th>
{ /* add condition like in tx table */ }
<Th w="120px" isNumeric>{ `Fee ${ config.chain.currency.symbol }` }</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<UserOpsTableItem key={ (isLoading ? String(index) : '') } item={ item } isLoading={ isLoading }/>
)) }
</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 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;
};
const WithdrawalsTableItem = ({ item, isLoading }: Props) => {
// will be fixed on the back-end
const timeAgo = dayjs(Number(item.timestamp) * 1000).fromNow();
return (
<Tr>
<Td verticalAlign="middle">
<UserOpEntity hash={ item.hash } noCopy={ false } isLoading={ isLoading }/>
</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>
<Td verticalAlign="middle">
<UserOpsAddress
address={ item.address }
isLoading={ isLoading }
truncation="constant"
/>
</Td>
<Td verticalAlign="middle">
<TxEntity
hash={ item.transaction_hash }
isLoading={ isLoading }
truncation="constant"
/>
</Td>
<Td verticalAlign="middle">
<BlockEntity
number={ item.block_number }
isLoading={ isLoading }
fontSize="sm"
lineHeight={ 5 }
/>
</Td>
<Td verticalAlign="middle" isNumeric>
<CurrencyValue value={ item.fee } isLoading={ isLoading } accuracy={ 8 }/>
</Td>
</Tr>
);
};
export default WithdrawalsTableItem;
......@@ -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 }
>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isPlaceholderData }/>
<Skeleton isLoaded={ !isPlaceholderData } ml={ 1 }>
{ dayjs(data.timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData } whiteSpace="normal">
{ dayjs(data.timestamp).format('llll') }
</Skeleton>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title="Verify tx hash"
......
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