Commit 5d908544 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Graceful service degradation: tx details tab (#1525)

* display main data

* display rest of tx fields

* placeholder data

* always show tabs

* manage error and retries on details tab

* don't retry if degraded view is active

* fix tests

* fix tx receipt

* change styles and wording for warning

* tweaks

* gray alert style change and update screenshots

* add retries to RPC call

* fixes after merge
parent f6ccec07
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30">
<path fill="currentColor" d="M22.576 11.994a1.212 1.212 0 0 0 0-2.424H9.14L10.703 8a1.212 1.212 0 0 0-1.709-1.709L5.358 9.928a1.212 1.212 0 0 0-.267 1.32 1.212 1.212 0 0 0 1.121.746h16.364Z"/>
<path fill="currentColor" fill-rule="evenodd" d="M15.859 18.661c.091-.363.14-.754.14-1.161 0-.445-.059-.871-.167-1.263h7.955a1.213 1.213 0 0 1 1.121.745 1.212 1.212 0 0 1-.267 1.321l-3.636 3.637a1.214 1.214 0 0 1-2.049-.347 1.212 1.212 0 0 1 .34-1.362l1.564-1.57h-5.001ZM12.226 20.523a3.636 3.636 0 0 0 1.275-1.83 3.615 3.615 0 0 0-.035-2.225 3.638 3.638 0 0 0-1.334-1.787 3.675 3.675 0 0 0-4.264 0 3.638 3.638 0 0 0-1.333 1.787 3.615 3.615 0 0 0-.036 2.226 3.636 3.636 0 0 0 1.275 1.829 5.482 5.482 0 0 0-2.714 2.606.588.588 0 0 0 .038.583.593.593 0 0 0 .51.288h1.413C7.2 22.76 8.465 21.8 10 21.8c1.535 0 2.8.96 2.979 2.2h1.412a.599.599 0 0 0 .511-.288.588.588 0 0 0 .038-.583 5.482 5.482 0 0 0-2.714-2.606ZM11.7 17.5a1.7 1.7 0 1 1-3.4 0 1.7 1.7 0 0 1 3.4 0Z" clip-rule="evenodd"/>
<path fill="currentColor" fill-rule="evenodd" d="M15.859 18.661c.091-.363.14-.754.14-1.161 0-.445-.059-.871-.167-1.263h7.955a1.213 1.213 0 0 1 1.121.745 1.212 1.212 0 0 1-.267 1.321l-3.636 3.637a1.214 1.214 0 0 1-2.049-.347 1.212 1.212 0 0 1 .34-1.362l1.564-1.57h-5.001Zm-3.633 1.862a3.636 3.636 0 0 0 1.275-1.83 3.615 3.615 0 0 0-.035-2.225 3.638 3.638 0 0 0-1.334-1.787 3.675 3.675 0 0 0-4.264 0 3.638 3.638 0 0 0-1.333 1.787 3.615 3.615 0 0 0-.036 2.226 3.636 3.636 0 0 0 1.275 1.829 5.482 5.482 0 0 0-2.714 2.606.588.588 0 0 0 .038.583.593.593 0 0 0 .51.288h1.413C7.2 22.76 8.465 21.8 10 21.8c1.535 0 2.8.96 2.979 2.2h1.412a.599.599 0 0 0 .511-.288.588.588 0 0 0 .038-.583 5.482 5.482 0 0 0-2.714-2.606ZM11.7 17.5a1.7 1.7 0 1 1-3.4 0 1.7 1.7 0 0 1 3.4 0Z" clip-rule="evenodd"/>
</svg>
......@@ -4,12 +4,7 @@ import React from 'react';
import getErrorObjPayload from 'lib/errors/getErrorObjPayload';
import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode';
export default function useQueryClientConfig() {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
export const retry = (failureCount: number, error: unknown) => {
const errorPayload = getErrorObjPayload<{ status: number }>(error);
const status = errorPayload?.status || getErrorObjStatusCode(error);
if (status && status >= 400 && status < 500) {
......@@ -17,7 +12,14 @@ export default function useQueryClientConfig() {
return false;
}
return failureCount < 2;
},
};
export default function useQueryClientConfig() {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry,
throwOnError: (error) => {
const status = getErrorObjStatusCode(error);
// don't catch error for "Too many requests" response
......
export default function hetToDecimal(hex: string) {
const strippedHex = hex.startsWith('0x') ? hex.slice(2) : hex;
return parseInt(strippedHex, 16);
}
import { createPublicClient, http } from 'viem';
import currentChain from './currentChain';
export const publicClient = createPublicClient({
chain: currentChain,
transport: http(),
batch: {
multicall: true,
},
});
import type { Chain } from 'wagmi';
import config from 'configs/app';
const currentChain: Chain = {
id: Number(config.chain.id),
name: config.chain.name ?? '',
network: config.chain.name ?? '',
nativeCurrency: {
decimals: config.chain.currency.decimals,
name: config.chain.currency.name ?? '',
symbol: config.chain.currency.symbol ?? '',
},
rpcUrls: {
'public': {
http: [ config.chain.rpcUrl ?? '' ],
},
'default': {
http: [ config.chain.rpcUrl ?? '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: config.app.baseUrl,
},
},
};
export default currentChain;
import type { Chain, GetBlockReturnType, GetTransactionReturnType, TransactionReceipt } from 'viem';
import { ADDRESS_HASH } from './addressParams';
import { BLOCK_HASH } from './block';
import { TX_HASH } from './tx';
export const GET_TRANSACTION: GetTransactionReturnType<Chain, 'latest'> = {
blockHash: BLOCK_HASH,
blockNumber: BigInt(10361367),
from: ADDRESS_HASH,
gas: BigInt(800000),
maxPriorityFeePerGas: BigInt(2),
maxFeePerGas: BigInt(14),
hash: TX_HASH,
input: '0x7898e0',
nonce: 117694,
to: ADDRESS_HASH,
transactionIndex: 60,
value: BigInt(42),
type: 'eip1559',
accessList: [],
chainId: 5,
v: BigInt(0),
r: '0x2c5022ff7f78a22f1a99afbd568f75cb52812189ed8c264c8310e0b8dba2c8a8',
s: '0x50938f87c92b9eeb9777507ca8f7397840232d00d1dbac3edac6c115b4656763',
yParity: 1,
typeHex: '0x2',
};
export const GET_TRANSACTION_RECEIPT: TransactionReceipt = {
blockHash: BLOCK_HASH,
blockNumber: BigInt(10361367),
contractAddress: null,
cumulativeGasUsed: BigInt(39109),
effectiveGasPrice: BigInt(13),
from: ADDRESS_HASH,
gasUsed: BigInt(39109),
logs: [],
logsBloom: '0x0',
status: 'success',
to: ADDRESS_HASH,
transactionHash: TX_HASH,
transactionIndex: 60,
type: '0x2',
};
export const GET_TRANSACTION_CONFIRMATIONS = BigInt(420);
export const GET_BLOCK: GetBlockReturnType<Chain, false, 'latest'> = {
baseFeePerGas: BigInt(11),
difficulty: BigInt(111),
extraData: '0xd8830',
gasLimit: BigInt(800000),
gasUsed: BigInt(42000),
hash: BLOCK_HASH,
logsBloom: '0x008000',
miner: ADDRESS_HASH,
mixHash: BLOCK_HASH,
nonce: '0x0000000000000000',
number: BigInt(10361367),
parentHash: BLOCK_HASH,
receiptsRoot: BLOCK_HASH,
sha3Uncles: BLOCK_HASH,
size: BigInt(88),
stateRoot: BLOCK_HASH,
timestamp: BigInt(1628580000),
totalDifficulty: BigInt(10361367),
transactions: [
TX_HASH,
],
transactionsRoot: TX_HASH,
uncles: [],
withdrawals: [ ],
withdrawalsRoot: TX_HASH,
sealFields: [ '0x00' ],
};
......@@ -56,10 +56,10 @@ const variantSubtle = definePartsStyle((props) => {
return {
container: {
[$fg.variable]: colorScheme === 'gray' ? 'colors.blackAlpha.800' : `colors.${ colorScheme }.500`,
[$bg.variable]: colorScheme === 'gray' ? 'colors.gray.100' : bg.light,
[$bg.variable]: colorScheme === 'gray' ? 'colors.blackAlpha.100' : bg.light,
_dark: {
[$fg.variable]: colorScheme === 'gray' ? 'colors.whiteAlpha.800' : `colors.${ colorScheme }.200`,
[$bg.variable]: colorScheme === 'gray' ? 'colors.gray.800' : bg.dark,
[$bg.variable]: colorScheme === 'gray' ? 'colors.whiteAlpha.200' : bg.dark,
},
},
};
......
......@@ -29,11 +29,11 @@ export type Transaction = {
status: 'ok' | 'error' | null;
block: number | null;
timestamp: string | null;
confirmation_duration: Array<number>;
confirmation_duration: Array<number> | null;
from: AddressParam;
value: string;
fee: Fee;
gas_price: string;
gas_price: string | null;
type: number | null;
gas_used: string | null;
gas_limit: string;
......@@ -49,7 +49,7 @@ export type Transaction = {
decoded_input: DecodedInput | null;
token_transfers: Array<TokenTransfer> | null;
token_transfers_overflow: boolean;
exchange_rate: string;
exchange_rate: string | null;
method: string | null;
tx_types: Array<TransactionType>;
tx_tag: string | null;
......
......@@ -4,10 +4,9 @@ import React from 'react';
import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx';
import TextAd from 'ui/shared/ad/TextAd';
import EntityTags from 'ui/shared/EntityTags';
import PageTitle from 'ui/shared/Page/PageTitle';
......@@ -15,6 +14,7 @@ import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxDetails from 'ui/tx/TxDetails';
import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded';
import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped';
import TxInternals from 'ui/tx/TxInternals';
import TxLogs from 'ui/tx/TxLogs';
......@@ -23,37 +23,42 @@ import TxState from 'ui/tx/TxState';
import TxSubHeading from 'ui/tx/TxSubHeading';
import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import TxUserOps from 'ui/tx/TxUserOps';
import useTxQuery from 'ui/tx/useTxQuery';
const TransactionPageContent = () => {
const router = useRouter();
const appProps = useAppContext();
const hash = getQueryParamString(router.query.hash);
const txQuery = useTxQuery();
const { data, isPlaceholderData, isError, error, errorUpdateCount } = txQuery;
const { data, isPlaceholderData } = useApiQuery('tx', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: TX,
},
});
const showDegradedView = (isError || isPlaceholderData) && errorUpdateCount > 0;
const tabs: Array<RoutedTab> = (() => {
const detailsComponent = showDegradedView ?
<TxDetailsDegraded hash={ hash } txQuery={ txQuery }/> :
<TxDetails txQuery={ txQuery }/>;
const tabs: Array<RoutedTab> = [
return [
{
id: 'index',
title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details',
component: <TxDetails/>,
component: detailsComponent,
},
config.features.suave.isEnabled && data?.wrapped ?
{ id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } :
undefined,
{ id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer/> },
config.features.userOps.isEnabled ? { id: 'user_ops', title: 'User operations', component: <TxUserOps/> } : undefined,
{ id: 'internal', title: 'Internal txns', component: <TxInternals/> },
{ id: 'logs', title: 'Logs', component: <TxLogs/> },
{ id: 'state', title: 'State', component: <TxState/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace/> },
{ id: 'token_transfers', title: 'Token transfers', component: <TxTokenTransfer txQuery={ txQuery }/> },
config.features.userOps.isEnabled ?
{ id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } :
undefined,
{ id: 'internal', title: 'Internal txns', component: <TxInternals txQuery={ txQuery }/> },
{ id: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery }/> },
{ id: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> },
].filter(Boolean);
})();
const tabIndex = useTabIndexFromQuery(tabs);
......@@ -79,6 +84,23 @@ const TransactionPageContent = () => {
const titleSecondRow = <TxSubHeading hash={ hash } hasTag={ Boolean(data?.tx_tag) }/>;
const content = (() => {
if (isPlaceholderData && !showDegradedView) {
return (
<>
<TabsSkeleton tabs={ tabs } mt={ 6 }/>
{ tabs[tabIndex]?.component }
</>
);
}
return <RoutedTabs tabs={ tabs }/>;
})();
if (error?.status === 422) {
throwOnResourceLoadError({ resource: 'tx', error, isError: true });
}
return (
<>
<TextAd mb={ 6 }/>
......@@ -88,12 +110,7 @@ const TransactionPageContent = () => {
contentAfter={ tags }
secondRow={ titleSecondRow }
/>
{ isPlaceholderData ? (
<>
<TabsSkeleton tabs={ tabs } mt={ 6 }/>
{ tabs[tabIndex]?.component }
</>
) : <RoutedTabs tabs={ tabs }/> }
{ content }
</>
);
};
......
......@@ -8,6 +8,8 @@ import type { RoutedTab } from 'ui/shared/Tabs/types';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnAbsentParamError from 'lib/errors/throwOnAbsentParamError';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { USER_OP } from 'stubs/userOps';
import TextAd from 'ui/shared/ad/TextAd';
......@@ -18,6 +20,7 @@ import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxLogs from 'ui/tx/TxLogs';
import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import useTxQuery from 'ui/tx/useTxQuery';
import UserOpDetails from 'ui/userOp/UserOpDetails';
import UserOpRaw from 'ui/userOp/UserOpRaw';
......@@ -27,13 +30,15 @@ const UserOp = () => {
const hash = getQueryParamString(router.query.hash);
const userOpQuery = useApiQuery('user_op', {
pathParams: { hash: hash },
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
placeholderData: USER_OP,
},
});
const txQuery = useTxQuery({ hash: userOpQuery.data?.transaction_hash, isEnabled: !userOpQuery.isPlaceholderData });
const filterTokenTransfersByLogIndex = React.useCallback((tt: TokenTransfer) => {
if (!userOpQuery.data) {
return true;
......@@ -61,22 +66,14 @@ const UserOp = () => {
{
id: 'token_transfers',
title: 'Token transfers',
component: <TxTokenTransfer txHash={ userOpQuery.data?.transaction_hash } tokenTransferFilter={ filterTokenTransfersByLogIndex }/>,
component: <TxTokenTransfer txQuery={ txQuery } tokenTransferFilter={ filterTokenTransfersByLogIndex }/>,
},
{ id: 'logs', title: 'Logs', component: <TxLogs txHash={ userOpQuery.data?.transaction_hash } logsFilter={ filterLogsByLogIndex }/> },
{ id: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery } logsFilter={ filterLogsByLogIndex }/> },
{ id: 'raw', title: 'Raw', component: <UserOpRaw rawData={ userOpQuery.data?.raw } isLoading={ userOpQuery.isPlaceholderData }/> },
]), [ userOpQuery, filterTokenTransfersByLogIndex, filterLogsByLogIndex ]);
]), [ userOpQuery, txQuery, 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');
......@@ -90,6 +87,9 @@ const UserOp = () => {
};
}, [ appProps.referrer ]);
throwOnAbsentParamError(hash);
throwOnResourceLoadError(userOpQuery);
const titleSecondRow = <UserOpEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } fontFamily="heading"/>;
return (
......
......@@ -18,7 +18,7 @@ const DetailsTimestamp = ({ timestamp, isLoading }: Props) => {
<Skeleton isLoaded={ !isLoading } ml={ 2 }>
{ dayjs(timestamp).fromNow() }
</Skeleton>
<TextSeparator/>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isLoading } whiteSpace="normal">
{ dayjs(timestamp).format('llll') }
</Skeleton>
......
......@@ -2,10 +2,10 @@ import { useColorMode } from '@chakra-ui/react';
import { jsonRpcProvider } from '@wagmi/core/providers/jsonRpc';
import { createWeb3Modal, useWeb3ModalTheme, defaultWagmiConfig } from '@web3modal/wagmi/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, WagmiConfig } from 'wagmi';
import config from 'configs/app';
import currentChain from 'lib/web3/currentChain';
import colors from 'theme/foundations/colors';
import { BODY_TYPEFACE } from 'theme/foundations/typography';
import zIndices from 'theme/foundations/zIndices';
......@@ -18,31 +18,6 @@ const getConfig = () => {
throw new Error();
}
const currentChain: Chain = {
id: Number(config.chain.id),
name: config.chain.name || '',
network: config.chain.name || '',
nativeCurrency: {
decimals: config.chain.currency.decimals,
name: config.chain.currency.name || '',
symbol: config.chain.currency.symbol || '',
},
rpcUrls: {
'public': {
http: [ config.chain.rpcUrl || '' ],
},
'default': {
http: [ config.chain.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: config.app.baseUrl,
},
},
};
const { chains } = configureChains(
[ currentChain ],
[
......
import { Alert, Skeleton, Spinner, chakra } from '@chakra-ui/react';
import React from 'react';
interface Props {
isLoading?: boolean;
className?: string;
}
const ServiceDegradationWarning = ({ isLoading, className }: Props) => {
return (
<Skeleton className={ className } isLoaded={ !isLoading }>
<Alert status="warning" colorScheme="gray" alignItems={{ base: 'flex-start', lg: 'center' }}>
<Spinner size="sm" mr={ 2 } my={{ base: '3px', lg: 0 }} flexShrink={ 0 }/>
Data sync in progress... page will refresh automatically once transaction data is available
</Alert>
</Skeleton>
);
};
export default React.memo(chakra(ServiceDegradationWarning));
import { Alert, Skeleton, chakra } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
interface Props {
isLoading?: boolean;
className?: string;
}
const TestnetWarning = ({ isLoading, className }: Props) => {
if (!config.chain.isTestnet) {
return null;
}
return (
<Skeleton className={ className } isLoaded={ !isLoading }>
<Alert status="warning">This is a testnet transaction only</Alert>
</Skeleton>
);
};
export default React.memo(chakra(TestnetWarning));
import {
Grid,
GridItem,
Text,
Box,
Link,
Spinner,
Flex,
Tooltip,
chakra,
useColorModeValue,
Skeleton,
Alert,
} from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import { ZKEVM_L2_TX_STATUSES } from 'types/api/transaction';
import TestnetWarning from 'ui/shared/alerts/TestnetWarning';
import { route } from 'nextjs-routes';
import TxInfo from './details/TxInfo';
import type { TxQuery } from './useTxQuery';
import config from 'configs/app';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import CurrencyValue from 'ui/shared/CurrencyValue';
import 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';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LogDecodedInputData from 'ui/shared/logs/LogDecodedInputData';
import RawInputData from 'ui/shared/RawInputData';
import TxStatus from 'ui/shared/statusTag/TxStatus';
import TextSeparator from 'ui/shared/TextSeparator';
import TxFeeStability from 'ui/shared/tx/TxFeeStability';
import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
import TxDetailsTokenTransfers from 'ui/tx/details/TxDetailsTokenTransfers';
import TxDetailsWithdrawalStatus from 'ui/tx/details/TxDetailsWithdrawalStatus';
import TxRevertReason from 'ui/tx/details/TxRevertReason';
import TxAllowedPeekers from 'ui/tx/TxAllowedPeekers';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxDetails = () => {
const { data, isPlaceholderData, isError, socketStatus, error } = useFetchTxInfo();
const [ isExpanded, setIsExpanded ] = React.useState(false);
const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag);
scroller.scrollTo('TxDetails__cutLink', {
duration: 500,
smooth: true,
});
}, []);
const executionSuccessIconColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
if (isError) {
if (error?.status === 422 || error?.status === 404) {
throwOnResourceLoadError({ isError, error, resource: 'tx' });
}
return <DataFetchAlert/>;
}
if (!data) {
return null;
}
const addressFromTags = [
...data.from.private_tags || [],
...data.from.public_tags || [],
...data.from.watchlist_names || [],
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const toAddress = data.to ? data.to : data.created_contract;
const addressToTags = [
...toAddress?.private_tags || [],
...toAddress?.public_tags || [],
...toAddress?.watchlist_names || [],
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const executionSuccessBadge = toAddress?.is_contract && data.result === 'success' ? (
<Tooltip label="Contract execution completed">
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
<IconSvg name="status/success" boxSize={ 4 } color={ executionSuccessIconColor } cursor="pointer"/>
</chakra.span>
</Tooltip>
) : null;
const executionFailedBadge = toAddress?.is_contract && Boolean(data.status) && data.result !== 'success' ? (
<Tooltip label="Error occurred during contract execution">
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
<IconSvg name="status/error" boxSize={ 4 } color="error" cursor="pointer"/>
</chakra.span>
</Tooltip>
) : null;
interface Props {
txQuery: TxQuery;
}
const TxDetails = ({ txQuery }: Props) => {
return (
<>
{ config.chain.isTestnet && (
<Skeleton mb={ 6 } isLoaded={ !isPlaceholderData }>
<Alert status="warning">This is a testnet transaction only</Alert>
</Skeleton>
) }
<Grid columnGap={ 8 } rowGap={{ base: 3, lg: 3 }} templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}>
{ socketStatus && (
<GridItem colSpan={{ base: undefined, lg: 2 }} mb={ 2 }>
<TxSocketAlert status={ socketStatus }/>
</GridItem>
) }
<DetailsInfoItem
title="Transaction hash"
hint="Unique character string (TxID) assigned to every verified transaction"
flexWrap="nowrap"
isLoading={ isPlaceholderData }
>
{ data.status === null && <Spinner mr={ 2 } size="sm" flexShrink={ 0 }/> }
<Skeleton isLoaded={ !isPlaceholderData } overflow="hidden">
<HashStringShortenDynamic hash={ data.hash }/>
</Skeleton>
<CopyToClipboard text={ data.hash } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
<DetailsInfoItem
title={ config.features.zkEvmRollup.isEnabled ? 'L2 status and method' : 'Status and method' }
hint="Current transaction state: Success, Failed (Error), or Pending (In Process)"
isLoading={ isPlaceholderData }
>
<TxStatus status={ data.status } errorText={ data.status === 'error' ? data.result : undefined } isLoading={ isPlaceholderData }/>
{ data.method && (
<Tag colorScheme={ data.method === 'Multicall' ? 'teal' : 'gray' } isLoading={ isPlaceholderData } isTruncated ml={ 3 }>
{ data.method }
</Tag>
) }
</DetailsInfoItem>
{ config.features.optimisticRollup.isEnabled && data.op_withdrawals && data.op_withdrawals.length > 0 && (
<DetailsInfoItem
title="Withdrawal status"
hint="Detailed status progress of the transaction"
>
<Flex flexDir="column" rowGap={ 2 }>
{ data.op_withdrawals.map((withdrawal) => (
<Box key={ withdrawal.nonce }>
<Box mb={ 2 }>
<span>Nonce: </span>
<chakra.span fontWeight={ 600 }>{ withdrawal.nonce }</chakra.span>
</Box>
<TxDetailsWithdrawalStatus
status={ withdrawal.status }
l1TxHash={ withdrawal.l1_transaction_hash }
/>
</Box>
)) }
</Flex>
</DetailsInfoItem>
) }
{ data.zkevm_status && (
<DetailsInfoItem
title="Confirmation status"
hint="Status of the transaction confirmation path to L1"
isLoading={ isPlaceholderData }
>
<VerificationSteps currentStep={ data.zkevm_status } steps={ ZKEVM_L2_TX_STATUSES } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
) }
{ data.revert_reason && (
<DetailsInfoItem
title="Revert reason"
hint="The revert reason of the transaction"
>
<TxRevertReason { ...data.revert_reason }/>
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Block"
hint="Block number containing the transaction"
isLoading={ isPlaceholderData }
>
{ data.block === null ?
<Text>Pending</Text> : (
<BlockEntity
isLoading={ isPlaceholderData }
number={ data.block }
noIcon
/>
) }
{ Boolean(data.confirmations) && (
<>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isPlaceholderData } color="text_secondary">
<span>{ data.confirmations } Block confirmations</span>
</Skeleton>
</>
) }
</DetailsInfoItem>
{ data.zkevm_batch_number && (
<DetailsInfoItem
title="Tx batch"
hint="Batch index for this transaction"
isLoading={ isPlaceholderData }
>
<ZkEvmBatchEntityL2
isLoading={ isPlaceholderData }
number={ data.zkevm_batch_number }
/>
</DetailsInfoItem>
) }
{ data.timestamp && (
<DetailsInfoItem
title="Timestamp"
hint="Date & time of transaction inclusion, including length of time for confirmation"
isLoading={ isPlaceholderData }
>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isPlaceholderData }/>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isPlaceholderData } color="text_secondary">
<span>{ getConfirmationDuration(data.confirmation_duration) }</span>
</Skeleton>
</DetailsInfoItem>
) }
{ data.execution_node && (
<DetailsInfoItem
title="Kettle"
hint="Node that carried out the confidential computation"
isLoading={ isPlaceholderData }
>
<AddressEntity
address={ data.execution_node }
href={ route({ pathname: '/txs/kettle/[hash]', query: { hash: data.execution_node.hash } }) }
/>
</DetailsInfoItem>
) }
{ data.allowed_peekers && data.allowed_peekers.length > 0 && (
<TxAllowedPeekers items={ data.allowed_peekers }/>
) }
<DetailsSponsoredItem isLoading={ isPlaceholderData }/>
<DetailsInfoItemDivider/>
<TxDetailsActions hash={ data.hash } actions={ data.actions } isTxDataLoading={ isPlaceholderData }/>
<DetailsInfoItem
title="From"
hint="Address (external or contract) sending the transaction"
isLoading={ isPlaceholderData }
columnGap={ 3 }
>
<AddressEntity
address={ data.from }
isLoading={ isPlaceholderData }
/>
{ data.from.name && <Text>{ data.from.name }</Text> }
{ addressFromTags.length > 0 && (
<Flex columnGap={ 3 }>
{ addressFromTags }
</Flex>
) }
</DetailsInfoItem>
<DetailsInfoItem
title={ data.to?.is_contract ? 'Interacted with contract' : 'To' }
hint="Address (external or contract) receiving the transaction"
isLoading={ isPlaceholderData }
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
columnGap={ 3 }
>
{ toAddress ? (
<>
{ data.to && data.to.hash ? (
<Flex flexWrap="nowrap" alignItems="center" maxW="100%">
<AddressEntity
address={ toAddress }
isLoading={ isPlaceholderData }
/>
{ executionSuccessBadge }
{ executionFailedBadge }
</Flex>
) : (
<Flex width="100%" whiteSpace="pre" alignItems="center" flexShrink={ 0 }>
<span>[Contract </span>
<AddressEntity
address={ toAddress }
isLoading={ isPlaceholderData }
noIcon
/>
<span>created]</span>
{ executionSuccessBadge }
{ executionFailedBadge }
</Flex>
) }
{ addressToTags.length > 0 && (
<Flex columnGap={ 3 }>
{ addressToTags }
</Flex>
) }
</>
) : (
<span>[ Contract creation ]</span>
) }
</DetailsInfoItem>
{ data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash } isOverflow={ data.token_transfers_overflow }/> }
<DetailsInfoItemDivider/>
{ data.zkevm_sequence_hash && (
<DetailsInfoItem
title="Sequence tx hash"
flexWrap="nowrap"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData } overflow="hidden">
<HashStringShortenDynamic hash={ data.zkevm_sequence_hash }/>
</Skeleton>
<CopyToClipboard text={ data.zkevm_sequence_hash } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
) }
{ data.zkevm_verify_hash && (
<DetailsInfoItem
title="Verify tx hash"
flexWrap="nowrap"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData } overflow="hidden">
<HashStringShortenDynamic hash={ data.zkevm_verify_hash }/>
</Skeleton>
<CopyToClipboard text={ data.zkevm_verify_hash } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
) }
{ (data.zkevm_batch_number || data.zkevm_verify_hash) && <DetailsInfoItemDivider/> }
{ !config.UI.views.tx.hiddenFields?.value && (
<DetailsInfoItem
title="Value"
hint="Value sent in the native token (and USD) if applicable"
isLoading={ isPlaceholderData }
>
<CurrencyValue
value={ data.value }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
isLoading={ isPlaceholderData }
flexWrap="wrap"
/>
</DetailsInfoItem>
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<DetailsInfoItem
title="Transaction fee"
hint="Total transaction fee"
isLoading={ isPlaceholderData }
>
{ data.stability_fee ? (
<TxFeeStability data={ data.stability_fee } isLoading={ isPlaceholderData }/>
) : (
<CurrencyValue
value={ data.fee.value }
currency={ config.UI.views.tx.hiddenFields?.fee_currency ? '' : currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
isLoading={ isPlaceholderData }
/>
) }
</DetailsInfoItem>
) }
<TxDetailsGasPrice gasPrice={ data.gas_price } isLoading={ isPlaceholderData }/>
<TxDetailsFeePerGas txFee={ data.fee.value } gasUsed={ data.gas_used } isLoading={ isPlaceholderData }/>
<DetailsInfoItem
title="Gas usage & limit by txn"
hint="Actual gas amount used by the transaction"
isLoading={ isPlaceholderData }
>
<Skeleton isLoaded={ !isPlaceholderData }>{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isPlaceholderData }>{ BigNumber(data.gas_limit).toFormat() }</Skeleton>
<Utilization ml={ 4 } value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() } isLoading={ isPlaceholderData }/>
</DetailsInfoItem>
{ !config.UI.views.tx.hiddenFields?.gas_fees &&
(data.base_fee_per_gas || data.max_fee_per_gas || data.max_priority_fee_per_gas) && (
<DetailsInfoItem
title={ `Gas fees (${ currencyUnits.gwei })` }
// eslint-disable-next-line max-len
hint={ `
Base Fee refers to the network Base Fee at the time of the block,
while Max Fee & Max Priority Fee refer to the max amount a user is willing to pay
for their tx & to give to the ${ getNetworkValidatorTitle() } respectively
` }
isLoading={ isPlaceholderData }
>
{ data.base_fee_per_gas && (
<Skeleton isLoaded={ !isPlaceholderData }>
<Text as="span" fontWeight="500">Base: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.base_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
{ (data.max_fee_per_gas || data.max_priority_fee_per_gas) && <TextSeparator/> }
</Skeleton>
) }
{ data.max_fee_per_gas && (
<Box>
<Text as="span" fontWeight="500">Max: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
{ data.max_priority_fee_per_gas && <TextSeparator/> }
</Box>
) }
{ data.max_priority_fee_per_gas && (
<Box>
<Text as="span" fontWeight="500">Max priority: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
</Box>
) }
</DetailsInfoItem>
) }
{ data.tx_burnt_fee && !config.UI.views.tx.hiddenFields?.burnt_fees && !config.features.optimisticRollup.isEnabled && (
<DetailsInfoItem
title="Burnt fees"
hint={ `Amount of ${ currencyUnits.ether } burned for this transaction. Equals Block Base Fee per Gas * Gas Used` }
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500"/>
<CurrencyValue
value={ String(data.tx_burnt_fee) }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
ml={ 2 }
/>
</DetailsInfoItem>
) }
{ config.features.optimisticRollup.isEnabled && (
<>
{ data.l1_gas_used && (
<DetailsInfoItem
title="L1 gas used by txn"
hint="L1 gas used by transaction"
isLoading={ isPlaceholderData }
>
<Text>{ BigNumber(data.l1_gas_used).toFormat() }</Text>
</DetailsInfoItem>
) }
{ data.l1_gas_price && (
<DetailsInfoItem
title="L1 gas price"
hint="L1 gas price"
isLoading={ isPlaceholderData }
>
<Text mr={ 1 }>{ BigNumber(data.l1_gas_price).dividedBy(WEI).toFixed() } { currencyUnits.ether }</Text>
<Text variant="secondary">({ BigNumber(data.l1_gas_price).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })</Text>
</DetailsInfoItem>
) }
{ data.l1_fee && (
<DetailsInfoItem
title="L1 fee"
// eslint-disable-next-line max-len
hint={ `L1 Data Fee which is used to cover the L1 "security" cost from the batch submission mechanism. In combination with L2 execution fee, L1 fee makes the total amount of fees that a transaction pays.` }
isLoading={ isPlaceholderData }
>
<CurrencyValue
value={ data.l1_fee }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
/>
</DetailsInfoItem>
) }
{ data.l1_fee_scalar && (
<DetailsInfoItem
title="L1 fee scalar"
hint="A Dynamic overhead (fee scalar) premium, which serves as a buffer in case L1 prices rapidly increase."
isLoading={ isPlaceholderData }
>
<Text>{ data.l1_fee_scalar }</Text>
</DetailsInfoItem>
) }
</>
) }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Element name="TxDetails__cutLink">
<Skeleton isLoaded={ !isPlaceholderData } mt={ 6 } display="inline-block">
<Link
display="inline-block"
fontSize="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
onClick={ handleCutClick }
>
{ isExpanded ? 'Hide details' : 'View details' }
</Link>
</Skeleton>
</Element>
</GridItem>
{ isExpanded && (
<>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
<TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position }/>
<DetailsInfoItem
title="Raw input"
hint="Binary data included with the transaction. See logs tab for additional info"
>
<RawInputData hex={ data.raw_input }/>
</DetailsInfoItem>
{ data.decoded_input && (
<DetailsInfoItem
title="Decoded input data"
hint="Decoded input data"
>
<LogDecodedInputData data={ data.decoded_input }/>
</DetailsInfoItem>
) }
</>
) }
</Grid>
<TestnetWarning mb={ 6 } isLoading={ txQuery.isPlaceholderData }/>
<TxInfo data={ txQuery.data } isLoading={ txQuery.isPlaceholderData } socketStatus={ txQuery.socketStatus }/>
</>
);
};
export default TxDetails;
export default React.memo(TxDetails);
import { Flex } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { Chain, GetBlockReturnType, GetTransactionReturnType, TransactionReceipt } from 'viem';
import type { Transaction } from 'types/api/transaction';
import { SECOND } from 'lib/consts';
import dayjs from 'lib/date/dayjs';
import hexToDecimal from 'lib/hexToDecimal';
import { publicClient } from 'lib/web3/client';
import { GET_BLOCK, GET_TRANSACTION, GET_TRANSACTION_RECEIPT, GET_TRANSACTION_CONFIRMATIONS } from 'stubs/RPC';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
import TestnetWarning from 'ui/shared/alerts/TestnetWarning';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import TxInfo from './details/TxInfo';
import type { TxQuery } from './useTxQuery';
type RpcResponseType = [
GetTransactionReturnType<Chain, 'latest'>,
TransactionReceipt | null,
bigint | null,
GetBlockReturnType<Chain, false, 'latest'> | null,
];
interface Props {
hash: string;
txQuery: TxQuery;
}
const TxDetailsDegraded = ({ hash, txQuery }: Props) => {
const [ originalError ] = React.useState(txQuery.error);
const query = useQuery<RpcResponseType, unknown, Transaction | null>({
queryKey: [ 'RPC', 'tx', { hash } ],
queryFn: async() => {
const tx = await publicClient.getTransaction({ hash: hash as `0x${ string }` });
if (!tx) {
throw new Error('Not found');
}
const txReceipt = await publicClient.getTransactionReceipt({ hash: hash as `0x${ string }` }).catch(() => null);
const block = await publicClient.getBlock({ blockHash: tx.blockHash }).catch(() => null);
const latestBlock = await publicClient.getBlock().catch(() => null);
const confirmations = latestBlock && block ? latestBlock.number - block.number + BigInt(1) : null;
return [
tx,
txReceipt,
confirmations,
block,
];
},
select: (response) => {
const [ tx, txReceipt, txConfirmations, block ] = response;
const status = (() => {
if (!txReceipt) {
return null;
}
return txReceipt.status === 'success' ? 'ok' : 'error';
})();
const gasPrice = txReceipt?.effectiveGasPrice ?? tx.gasPrice;
const unknownAddress = {
is_contract: false,
is_verified: false,
implementation_name: '',
name: '',
private_tags: [],
public_tags: [],
watchlist_names: [],
ens_domain_name: null,
};
return {
from: { ...unknownAddress, hash: tx.from as string },
to: tx.to ? { ...unknownAddress, hash: tx.to as string } : null,
hash: tx.hash as string,
timestamp: block?.timestamp ? dayjs.unix(Number(block.timestamp)).format() : null,
confirmation_duration: null,
status,
block: tx.blockNumber ? Number(tx.blockNumber) : null,
value: tx.value.toString(),
gas_price: txReceipt?.effectiveGasPrice.toString() ?? tx.gasPrice?.toString() ?? null,
base_fee_per_gas: block?.baseFeePerGas?.toString() ?? null,
max_fee_per_gas: tx.maxFeePerGas?.toString() ?? null,
max_priority_fee_per_gas: tx.maxPriorityFeePerGas?.toString() ?? null,
nonce: tx.nonce,
position: tx.transactionIndex,
type: tx.typeHex ? hexToDecimal(tx.typeHex) : null,
raw_input: tx.input,
gas_used: txReceipt?.gasUsed?.toString() ?? null,
gas_limit: tx.gas.toString(),
confirmations: txConfirmations && txConfirmations > 0 ? Number(txConfirmations) : 0,
fee: {
value: txReceipt && gasPrice ? (txReceipt.gasUsed * gasPrice).toString() : null,
type: 'actual',
},
created_contract: txReceipt?.contractAddress ?
{ ...unknownAddress, hash: txReceipt.contractAddress, is_contract: true } :
null,
result: '',
priority_fee: null,
tx_burnt_fee: null,
revert_reason: null,
decoded_input: null,
has_error_in_internal_txs: null,
token_transfers: null,
token_transfers_overflow: false,
exchange_rate: null,
method: null,
tx_types: [],
tx_tag: null,
actions: [],
};
},
placeholderData: [
GET_TRANSACTION,
GET_TRANSACTION_RECEIPT,
GET_TRANSACTION_CONFIRMATIONS,
GET_BLOCK,
],
refetchOnMount: false,
enabled: !txQuery.isPlaceholderData,
retry: 2,
retryDelay: 5 * SECOND,
});
const hasData = Boolean(query.data);
React.useEffect(() => {
if (!query.isPlaceholderData && hasData) {
txQuery.setRefetchOnError.on();
}
}, [ hasData, query.isPlaceholderData, txQuery ]);
React.useEffect(() => {
return () => {
txQuery.setRefetchOnError.off();
};
}, [ txQuery.setRefetchOnError ]);
if (!query.data) {
if (originalError?.status === 404) {
throw Error('Not found', { cause: { status: 404 } as unknown as Error });
}
return <DataFetchAlert/>;
}
return (
<>
<Flex rowGap={ 2 } mb={ 6 } flexDir="column">
<TestnetWarning isLoading={ query.isPlaceholderData }/>
{ originalError?.status !== 404 && <ServiceDegradationWarning isLoading={ query.isPlaceholderData }/> }
</Flex>
<TxInfo data={ query.data } isLoading={ query.isPlaceholderData }/>
</>
);
};
export default React.memo(TxDetailsDegraded);
......@@ -7,9 +7,9 @@ import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TxInternals from './TxInternals';
import type { TxQuery } from './useTxQuery';
const TX_HASH = txMock.base.hash;
const API_URL_TX = buildApiUrl('tx', { hash: TX_HASH });
const API_URL_TX_INTERNALS = buildApiUrl('tx_internal_txs', { hash: TX_HASH });
const hooksConfig = {
router: {
......@@ -18,18 +18,20 @@ const hooksConfig = {
};
test('base view +@mobile', async({ mount, page }) => {
await page.route(API_URL_TX, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.base),
}));
await page.route(API_URL_TX_INTERNALS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(internalTxsMock.baseResponse),
}));
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
const component = await mount(
<TestApp>
<TxInternals/>
<TxInternals txQuery={ txQuery }/>
</TestApp>,
{ hooksConfig },
);
......
......@@ -3,7 +3,6 @@ import React from 'react';
import type { InternalTransaction } from 'types/api/internalTransaction';
import { SECOND } from 'lib/consts';
// import { apos } from 'lib/html-entities';
import { INTERNAL_TX } from 'stubs/internalTx';
import { generateListStub } from 'stubs/utils';
......@@ -19,7 +18,8 @@ import TxInternalsTable from 'ui/tx/internals/TxInternalsTable';
import type { Sort, SortField } from 'ui/tx/internals/utils';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import type { TxQuery } from './useTxQuery';
const SORT_SEQUENCE: Record<SortField, Array<Sort | undefined>> = {
value: [ 'value-desc', 'value-asc', undefined ],
......@@ -62,17 +62,20 @@ const sortFn = (sort: Sort | undefined) => (a: InternalTransaction, b: InternalT
// item.to.hash.toLowerCase().includes(formattedSearchTerm);
// };
const TxInternals = () => {
interface Props {
txQuery: TxQuery;
}
const TxInternals = ({ txQuery }: Props) => {
// filters are not implemented yet in api
// const [ filters, setFilters ] = React.useState<Array<TxInternalsType>>([]);
// const [ searchTerm, setSearchTerm ] = React.useState<string>('');
const [ sort, setSort ] = React.useState<Sort>();
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_internal_txs',
pathParams: { hash: txInfo.data?.hash },
pathParams: { hash: txQuery.data?.hash },
options: {
enabled: !txInfo.isPlaceholderData && Boolean(txInfo.data?.hash) && Boolean(txInfo.data?.status),
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: generateListStub<'tx_internal_txs'>(INTERNAL_TX, 3, { next_page_params: null }),
},
});
......@@ -90,8 +93,8 @@ const TxInternals = () => {
};
}, [ isPlaceholderData ]);
if (!txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data?.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data?.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
const filteredData = data?.items
......@@ -125,7 +128,7 @@ const TxInternals = () => {
return (
<DataListDisplay
isError={ isError || txInfo.isError }
isError={ isError || txQuery.isError }
items={ data?.items }
emptyText="There are no internal transactions for this transaction."
// filterProps={{
......
......@@ -3,7 +3,6 @@ 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';
import ActionBar from 'ui/shared/ActionBar';
......@@ -13,29 +12,29 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
type Props = {
txHash?: string;
import type { TxQuery } from './useTxQuery';
interface Props {
txQuery: TxQuery;
logsFilter?: (log: Log) => boolean;
}
const TxLogs = ({ txHash, logsFilter }: Props) => {
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND, txHash });
const TxLogs = ({ txQuery, logsFilter }: Props) => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_logs',
pathParams: { hash: txInfo.data?.hash },
pathParams: { hash: txQuery.data?.hash },
options: {
enabled: !txInfo.isPlaceholderData && Boolean(txInfo.data?.hash) && Boolean(txInfo.data?.status),
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: generateListStub<'tx_logs'>(LOG, 3, { next_page_params: null }),
},
});
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
if (isError || txInfo.isError) {
if (isError || txQuery.isError) {
return <DataFetchAlert/>;
}
......
......@@ -5,7 +5,6 @@ import type { SocketMessage } from 'lib/socket/types';
import type { RawTracesResponse } from 'types/api/rawTrace';
import useApiQuery from 'lib/api/useApiQuery';
import { SECOND } from 'lib/consts';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
......@@ -14,19 +13,23 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
const TxRawTrace = () => {
import type { TxQuery } from './useTxQuery';
interface Props {
txQuery: TxQuery;
}
const TxRawTrace = ({ txQuery }: Props) => {
const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false);
const [ rawTraces, setRawTraces ] = React.useState<RawTracesResponse>();
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
const { data, isPlaceholderData, isError } = useApiQuery('tx_raw_trace', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash) && Boolean(txInfo.data?.status) && isQueryEnabled,
enabled: Boolean(hash) && Boolean(txQuery.data?.status) && isQueryEnabled,
placeholderData: TX_RAW_TRACE,
},
});
......@@ -39,7 +42,7 @@ const TxRawTrace = () => {
const channel = useSocketChannel({
topic: `transactions:${ hash }`,
isDisabled: !hash || txInfo.isPlaceholderData || !txInfo.data?.status,
isDisabled: !hash || txQuery.isPlaceholderData || !txQuery.data?.status,
onJoin: enableQuery,
onSocketError: enableQuery,
});
......@@ -49,11 +52,11 @@ const TxRawTrace = () => {
handler: handleRawTraceMessage,
});
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
if (isError || txInfo.isError) {
if (isError || txQuery.isError) {
return <DataFetchAlert/>;
}
......
......@@ -7,8 +7,8 @@ import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TxState from './TxState';
import type { TxQuery } from './useTxQuery';
const TX_INFO_API_URL = buildApiUrl('tx', { hash: txMock.base.hash });
const TX_STATE_API_URL = buildApiUrl('tx_state_changes', { hash: txMock.base.hash });
const hooksConfig = {
router: {
......@@ -17,18 +17,20 @@ const hooksConfig = {
};
test('base view +@mobile', async({ mount, page }) => {
await page.route(TX_INFO_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.base),
}));
await page.route(TX_STATE_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txStateMock.baseResponse),
}));
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
const component = await mount(
<TestApp>
<TxState/>
<TxState txQuery={ txQuery }/>
</TestApp>,
{ hooksConfig },
);
......
import { Accordion, Hide, Show, Text } from '@chakra-ui/react';
import React from 'react';
import { SECOND } from 'lib/consts';
import { TX_STATE_CHANGES } from 'stubs/txStateChanges';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
......@@ -9,18 +8,21 @@ import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxStateList from 'ui/tx/state/TxStateList';
import TxStateTable from 'ui/tx/state/TxStateTable';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import TxPendingAlert from './TxPendingAlert';
import TxSocketAlert from './TxSocketAlert';
import type { TxQuery } from './useTxQuery';
const TxState = () => {
const txInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
interface Props {
txQuery: TxQuery;
}
const TxState = ({ txQuery }: Props) => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_state_changes',
pathParams: { hash: txInfo.data?.hash },
pathParams: { hash: txQuery.data?.hash },
options: {
enabled: !txInfo.isPlaceholderData && Boolean(txInfo.data?.hash) && Boolean(txInfo.data?.status),
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: {
items: TX_STATE_CHANGES,
next_page_params: {
......@@ -31,8 +33,8 @@ const TxState = () => {
},
});
if (!txInfo.isPending && !txInfo.isPlaceholderData && !txInfo.isError && !txInfo.data.status) {
return txInfo.socketStatus ? <TxSocketAlert status={ txInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
const content = data ? (
......@@ -54,12 +56,14 @@ const TxState = () => {
return (
<>
{ !isError && !txQuery.isError && (
<Text mb={ 6 }>
A set of information that represents the current state is updated when a transaction takes place on the network.
The below is a summary of those changes.
</Text>
) }
<DataListDisplay
isError={ isError }
isError={ isError || txQuery.isError }
items={ data?.items }
emptyText="There are no state changes for this transaction."
content={ content }
......
......@@ -5,7 +5,6 @@ import React from 'react';
import type { TokenType } from 'types/api/token';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import { SECOND } from 'lib/consts';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import { apos } from 'lib/html-entities';
import { TOKEN_TYPE_IDS } from 'lib/token/tokenTypes';
......@@ -20,27 +19,26 @@ import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import type { TxQuery } from './useTxQuery';
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPE_IDS);
type Props = {
txHash?: string;
tokenTransferFilter?: (tt: TokenTransfer) => boolean;
interface Props {
txQuery: TxQuery;
tokenTransferFilter?: (data: TokenTransfer) => boolean;
}
const TxTokenTransfer = ({ txHash, tokenTransferFilter }: Props) => {
const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND, txHash });
const TxTokenTransfer = ({ txQuery, tokenTransferFilter }: Props) => {
const router = useRouter();
const [ typeFilter, setTypeFilter ] = React.useState<Array<TokenType>>(getTokenFilterValue(router.query.type) || []);
const tokenTransferQuery = useQueryWithPages({
resourceName: 'tx_token_transfers',
pathParams: { hash: txsInfo.data?.hash.toString() },
pathParams: { hash: txQuery.data?.hash.toString() },
options: {
enabled: !txsInfo.isPlaceholderData && Boolean(txsInfo.data?.status && txsInfo.data?.hash),
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.status && txQuery.data?.hash),
placeholderData: getTokenTransfersStub(),
},
filters: { type: typeFilter },
......@@ -51,11 +49,11 @@ const TxTokenTransfer = ({ txHash, tokenTransferFilter }: Props) => {
setTypeFilter(nextValue);
}, [ tokenTransferQuery ]);
if (!txsInfo.isPending && !txsInfo.isPlaceholderData && !txsInfo.isError && !txsInfo.data.status) {
return txsInfo.socketStatus ? <TxSocketAlert status={ txsInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
if (txsInfo.isError || tokenTransferQuery.isError) {
if (txQuery.isError || tokenTransferQuery.isError) {
return <DataFetchAlert/>;
}
......@@ -97,7 +95,7 @@ const TxTokenTransfer = ({ txHash, tokenTransferFilter }: Props) => {
return (
<DataListDisplay
isError={ txsInfo.isError || tokenTransferQuery.isError }
isError={ txQuery.isError || tokenTransferQuery.isError }
items={ items }
emptyText="There are no token transfers."
filterProps={{
......
import React from 'react';
import { SECOND } from 'lib/consts';
import { USER_OPS_ITEM } from 'stubs/userOps';
import { generateListStub } from 'stubs/utils';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxPendingAlert from 'ui/tx/TxPendingAlert';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
import useFetchTxInfo from 'ui/tx/useFetchTxInfo';
import UserOpsContent from 'ui/userOps/UserOpsContent';
const TxUserOps = () => {
const txsInfo = useFetchTxInfo({ updateDelay: 5 * SECOND });
import type { TxQuery } from './useTxQuery';
interface Props {
txQuery: TxQuery;
}
const TxUserOps = ({ txQuery }: Props) => {
const userOpsQuery = useQueryWithPages({
resourceName: 'user_ops',
options: {
enabled: !txsInfo.isPlaceholderData && Boolean(txsInfo.data?.status && txsInfo.data?.hash),
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.status && txQuery.data?.hash),
// most often there is only one user op in one tx
placeholderData: generateListStub<'user_ops'>(USER_OPS_ITEM, 1, { next_page_params: null }),
},
filters: { transaction_hash: txsInfo.data?.hash },
filters: { transaction_hash: txQuery.data?.hash },
});
if (!txsInfo.isPending && !txsInfo.isPlaceholderData && !txsInfo.isError && !txsInfo.data.status) {
return txsInfo.socketStatus ? <TxSocketAlert status={ txsInfo.socketStatus }/> : <TxPendingAlert/>;
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
return <UserOpsContent query={ userOpsQuery } showTx={ false }/>;
......
......@@ -8,12 +8,12 @@ import { currencyUnits } from 'lib/units';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
interface Props {
gasPrice: string;
gasPrice: string | null;
isLoading?: boolean;
}
const TxDetailsGasPrice = ({ gasPrice, isLoading }: Props) => {
if (config.UI.views.tx.hiddenFields?.gas_price) {
if (config.UI.views.tx.hiddenFields?.gas_price || !gasPrice) {
return null;
}
......
......@@ -4,12 +4,10 @@ import React from 'react';
import * as txMock from 'mocks/txs/tx';
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import TxDetails from './TxDetails';
import TxInfo from './TxInfo';
const API_URL = buildApiUrl('tx', { hash: '1' });
const hooksConfig = {
router: {
query: { hash: 1 },
......@@ -17,14 +15,9 @@ const hooksConfig = {
};
test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.base),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.base } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -38,14 +31,9 @@ test('between addresses +@mobile +@dark-mode', async({ mount, page }) => {
});
test('creating contact', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withContractCreation),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withContractCreation } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -57,14 +45,9 @@ test('creating contact', async({ mount, page }) => {
});
test('with token transfer +@mobile', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withTokenTransfer),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withTokenTransfer } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -76,14 +59,9 @@ test('with token transfer +@mobile', async({ mount, page }) => {
});
test('with decoded revert reason', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withDecodedRevertReason),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withDecodedRevertReason } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -95,14 +73,9 @@ test('with decoded revert reason', async({ mount, page }) => {
});
test('with decoded raw reason', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withRawRevertReason),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withRawRevertReason } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -114,14 +87,9 @@ test('with decoded raw reason', async({ mount, page }) => {
});
test('pending', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.pending),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.pending } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -135,14 +103,9 @@ test('pending', async({ mount, page }) => {
});
test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.withActionsUniswap),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.withActionsUniswap } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -159,14 +122,9 @@ const l2Test = test.extend({
});
l2Test('l2', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.l2tx),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -185,14 +143,9 @@ const mainnetTest = test.extend({
});
mainnetTest('without testnet warning', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.l2tx),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.l2tx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......@@ -209,14 +162,9 @@ const stabilityTest = test.extend({
});
stabilityTest('stability customization', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(txMock.stabilityTx),
}));
const component = await mount(
<TestApp>
<TxDetails/>
<TxInfo data={ txMock.stabilityTx } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
......
import {
Box,
Grid,
GridItem,
Text,
Link,
Spinner,
Flex,
Tooltip,
chakra,
useColorModeValue,
Skeleton,
} from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import { scroller, Element } from 'react-scroll';
import type { Transaction } from 'types/api/transaction';
import { ZKEVM_L2_TX_STATUSES } from 'types/api/transaction';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import getNetworkValidatorTitle from 'lib/networks/getNetworkValidatorTitle';
import getConfirmationDuration from 'lib/tx/getConfirmationDuration';
import { currencyUnits } from 'lib/units';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import ZkEvmBatchEntityL2 from 'ui/shared/entities/block/ZkEvmBatchEntityL2';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import LogDecodedInputData from 'ui/shared/logs/LogDecodedInputData';
import RawInputData from 'ui/shared/RawInputData';
import TxStatus from 'ui/shared/statusTag/TxStatus';
import TextSeparator from 'ui/shared/TextSeparator';
import TxFeeStability from 'ui/shared/tx/TxFeeStability';
import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
import TxDetailsTokenTransfers from 'ui/tx/details/TxDetailsTokenTransfers';
import TxDetailsWithdrawalStatus from 'ui/tx/details/TxDetailsWithdrawalStatus';
import TxRevertReason from 'ui/tx/details/TxRevertReason';
import TxAllowedPeekers from 'ui/tx/TxAllowedPeekers';
import TxSocketAlert from 'ui/tx/TxSocketAlert';
interface Props {
data: Transaction | undefined;
isLoading: boolean;
socketStatus?: 'close' | 'error';
}
const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
const [ isExpanded, setIsExpanded ] = React.useState(false);
const handleCutClick = React.useCallback(() => {
setIsExpanded((flag) => !flag);
scroller.scrollTo('TxInfo__cutLink', {
duration: 500,
smooth: true,
});
}, []);
const executionSuccessIconColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
if (!data) {
return null;
}
const addressFromTags = [
...data.from.private_tags || [],
...data.from.public_tags || [],
...data.from.watchlist_names || [],
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const toAddress = data.to ? data.to : data.created_contract;
const addressToTags = [
...toAddress?.private_tags || [],
...toAddress?.public_tags || [],
...toAddress?.watchlist_names || [],
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
const executionSuccessBadge = toAddress?.is_contract && data.result === 'success' ? (
<Tooltip label="Contract execution completed">
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
<IconSvg name="status/success" boxSize={ 4 } color={ executionSuccessIconColor } cursor="pointer"/>
</chakra.span>
</Tooltip>
) : null;
const executionFailedBadge = toAddress?.is_contract && Boolean(data.status) && data.result !== 'success' ? (
<Tooltip label="Error occurred during contract execution">
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
<IconSvg name="status/error" boxSize={ 4 } color="error" cursor="pointer"/>
</chakra.span>
</Tooltip>
) : null;
return (
<Grid columnGap={ 8 } rowGap={{ base: 3, lg: 3 }} templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}>
{ socketStatus && (
<GridItem colSpan={{ base: undefined, lg: 2 }} mb={ 2 }>
<TxSocketAlert status={ socketStatus }/>
</GridItem>
) }
<DetailsInfoItem
title="Transaction hash"
hint="Unique character string (TxID) assigned to every verified transaction"
flexWrap="nowrap"
isLoading={ isLoading }
>
{ data.status === null && <Spinner mr={ 2 } size="sm" flexShrink={ 0 }/> }
<Skeleton isLoaded={ !isLoading } overflow="hidden">
<HashStringShortenDynamic hash={ data.hash }/>
</Skeleton>
<CopyToClipboard text={ data.hash } isLoading={ isLoading }/>
</DetailsInfoItem>
<DetailsInfoItem
title={ config.features.zkEvmRollup.isEnabled ? 'L2 status and method' : 'Status and method' }
hint="Current transaction state: Success, Failed (Error), or Pending (In Process)"
isLoading={ isLoading }
>
<TxStatus status={ data.status } errorText={ data.status === 'error' ? data.result : undefined } isLoading={ isLoading }/>
{ data.method && (
<Tag colorScheme={ data.method === 'Multicall' ? 'teal' : 'gray' } isLoading={ isLoading } isTruncated ml={ 3 }>
{ data.method }
</Tag>
) }
</DetailsInfoItem>
{ config.features.optimisticRollup.isEnabled && data.op_withdrawals && data.op_withdrawals.length > 0 && (
<DetailsInfoItem
title="Withdrawal status"
hint="Detailed status progress of the transaction"
>
<Flex flexDir="column" rowGap={ 2 }>
{ data.op_withdrawals.map((withdrawal) => (
<Box key={ withdrawal.nonce }>
<Box mb={ 2 }>
<span>Nonce: </span>
<chakra.span fontWeight={ 600 }>{ withdrawal.nonce }</chakra.span>
</Box>
<TxDetailsWithdrawalStatus
status={ withdrawal.status }
l1TxHash={ withdrawal.l1_transaction_hash }
/>
</Box>
)) }
</Flex>
</DetailsInfoItem>
) }
{ data.zkevm_status && (
<DetailsInfoItem
title="Confirmation status"
hint="Status of the transaction confirmation path to L1"
isLoading={ isLoading }
>
<VerificationSteps currentStep={ data.zkevm_status } steps={ ZKEVM_L2_TX_STATUSES } isLoading={ isLoading }/>
</DetailsInfoItem>
) }
{ data.revert_reason && (
<DetailsInfoItem
title="Revert reason"
hint="The revert reason of the transaction"
>
<TxRevertReason { ...data.revert_reason }/>
</DetailsInfoItem>
) }
<DetailsInfoItem
title="Block"
hint="Block number containing the transaction"
isLoading={ isLoading }
>
{ data.block === null ?
<Text>Pending</Text> : (
<BlockEntity
isLoading={ isLoading }
number={ data.block }
noIcon
/>
) }
{ Boolean(data.confirmations) && (
<>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ data.confirmations } Block confirmations</span>
</Skeleton>
</>
) }
</DetailsInfoItem>
{ data.zkevm_batch_number && (
<DetailsInfoItem
title="Tx batch"
hint="Batch index for this transaction"
isLoading={ isLoading }
>
<ZkEvmBatchEntityL2
isLoading={ isLoading }
number={ data.zkevm_batch_number }
/>
</DetailsInfoItem>
) }
{ data.timestamp && (
<DetailsInfoItem
title="Timestamp"
hint="Date & time of transaction inclusion, including length of time for confirmation"
isLoading={ isLoading }
>
<DetailsTimestamp timestamp={ data.timestamp } isLoading={ isLoading }/>
{ data.confirmation_duration && (
<>
<TextSeparator color="gray.500"/>
<Skeleton isLoaded={ !isLoading } color="text_secondary">
<span>{ getConfirmationDuration(data.confirmation_duration) }</span>
</Skeleton>
</>
) }
</DetailsInfoItem>
) }
{ data.execution_node && (
<DetailsInfoItem
title="Kettle"
hint="Node that carried out the confidential computation"
isLoading={ isLoading }
>
<AddressEntity
address={ data.execution_node }
href={ route({ pathname: '/txs/kettle/[hash]', query: { hash: data.execution_node.hash } }) }
/>
</DetailsInfoItem>
) }
{ data.allowed_peekers && data.allowed_peekers.length > 0 && (
<TxAllowedPeekers items={ data.allowed_peekers }/>
) }
<DetailsSponsoredItem isLoading={ isLoading }/>
<DetailsInfoItemDivider/>
<TxDetailsActions hash={ data.hash } actions={ data.actions } isTxDataLoading={ isLoading }/>
<DetailsInfoItem
title="From"
hint="Address (external or contract) sending the transaction"
isLoading={ isLoading }
columnGap={ 3 }
>
<AddressEntity
address={ data.from }
isLoading={ isLoading }
/>
{ data.from.name && <Text>{ data.from.name }</Text> }
{ addressFromTags.length > 0 && (
<Flex columnGap={ 3 }>
{ addressFromTags }
</Flex>
) }
</DetailsInfoItem>
<DetailsInfoItem
title={ data.to?.is_contract ? 'Interacted with contract' : 'To' }
hint="Address (external or contract) receiving the transaction"
isLoading={ isLoading }
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
columnGap={ 3 }
>
{ toAddress ? (
<>
{ data.to && data.to.hash ? (
<Flex flexWrap="nowrap" alignItems="center" maxW="100%">
<AddressEntity
address={ toAddress }
isLoading={ isLoading }
/>
{ executionSuccessBadge }
{ executionFailedBadge }
</Flex>
) : (
<Flex width="100%" whiteSpace="pre" alignItems="center" flexShrink={ 0 }>
<span>[Contract </span>
<AddressEntity
address={ toAddress }
isLoading={ isLoading }
noIcon
/>
<span>created]</span>
{ executionSuccessBadge }
{ executionFailedBadge }
</Flex>
) }
{ addressToTags.length > 0 && (
<Flex columnGap={ 3 }>
{ addressToTags }
</Flex>
) }
</>
) : (
<span>[ Contract creation ]</span>
) }
</DetailsInfoItem>
{ data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash } isOverflow={ data.token_transfers_overflow }/> }
<DetailsInfoItemDivider/>
{ data.zkevm_sequence_hash && (
<DetailsInfoItem
title="Sequence tx hash"
flexWrap="nowrap"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden">
<HashStringShortenDynamic hash={ data.zkevm_sequence_hash }/>
</Skeleton>
<CopyToClipboard text={ data.zkevm_sequence_hash } isLoading={ isLoading }/>
</DetailsInfoItem>
) }
{ data.zkevm_verify_hash && (
<DetailsInfoItem
title="Verify tx hash"
flexWrap="nowrap"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden">
<HashStringShortenDynamic hash={ data.zkevm_verify_hash }/>
</Skeleton>
<CopyToClipboard text={ data.zkevm_verify_hash } isLoading={ isLoading }/>
</DetailsInfoItem>
) }
{ (data.zkevm_batch_number || data.zkevm_verify_hash) && <DetailsInfoItemDivider/> }
{ !config.UI.views.tx.hiddenFields?.value && (
<DetailsInfoItem
title="Value"
hint="Value sent in the native token (and USD) if applicable"
isLoading={ isLoading }
>
<CurrencyValue
value={ data.value }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
isLoading={ isLoading }
flexWrap="wrap"
/>
</DetailsInfoItem>
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
<DetailsInfoItem
title="Transaction fee"
hint="Total transaction fee"
isLoading={ isLoading }
>
{ data.stability_fee ? (
<TxFeeStability data={ data.stability_fee } isLoading={ isLoading }/>
) : (
<CurrencyValue
value={ data.fee.value }
currency={ config.UI.views.tx.hiddenFields?.fee_currency ? '' : currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
isLoading={ isLoading }
/>
) }
</DetailsInfoItem>
) }
<TxDetailsGasPrice gasPrice={ data.gas_price } isLoading={ isLoading }/>
<TxDetailsFeePerGas txFee={ data.fee.value } gasUsed={ data.gas_used } isLoading={ isLoading }/>
<DetailsInfoItem
title="Gas usage & limit by txn"
hint="Actual gas amount used by the transaction"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading }>{ BigNumber(data.gas_used || 0).toFormat() }</Skeleton>
<TextSeparator/>
<Skeleton isLoaded={ !isLoading }>{ BigNumber(data.gas_limit).toFormat() }</Skeleton>
<Utilization ml={ 4 } value={ BigNumber(data.gas_used || 0).dividedBy(BigNumber(data.gas_limit)).toNumber() } isLoading={ isLoading }/>
</DetailsInfoItem>
{ !config.UI.views.tx.hiddenFields?.gas_fees &&
(data.base_fee_per_gas || data.max_fee_per_gas || data.max_priority_fee_per_gas) && (
<DetailsInfoItem
title={ `Gas fees (${ currencyUnits.gwei })` }
// eslint-disable-next-line max-len
hint={ `
Base Fee refers to the network Base Fee at the time of the block,
while Max Fee & Max Priority Fee refer to the max amount a user is willing to pay
for their tx & to give to the ${ getNetworkValidatorTitle() } respectively
` }
isLoading={ isLoading }
>
{ data.base_fee_per_gas && (
<Skeleton isLoaded={ !isLoading }>
<Text as="span" fontWeight="500">Base: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.base_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
{ (data.max_fee_per_gas || data.max_priority_fee_per_gas) && <TextSeparator/> }
</Skeleton>
) }
{ data.max_fee_per_gas && (
<Skeleton isLoaded={ !isLoading }>
<Text as="span" fontWeight="500">Max: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
{ data.max_priority_fee_per_gas && <TextSeparator/> }
</Skeleton>
) }
{ data.max_priority_fee_per_gas && (
<Skeleton isLoaded={ !isLoading }>
<Text as="span" fontWeight="500">Max priority: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
</Skeleton>
) }
</DetailsInfoItem>
) }
{ data.tx_burnt_fee && !config.UI.views.tx.hiddenFields?.burnt_fees && !config.features.optimisticRollup.isEnabled && (
<DetailsInfoItem
title="Burnt fees"
hint={ `Amount of ${ currencyUnits.ether } burned for this transaction. Equals Block Base Fee per Gas * Gas Used` }
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500"/>
<CurrencyValue
value={ String(data.tx_burnt_fee) }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
ml={ 2 }
/>
</DetailsInfoItem>
) }
{ config.features.optimisticRollup.isEnabled && (
<>
{ data.l1_gas_used && (
<DetailsInfoItem
title="L1 gas used by txn"
hint="L1 gas used by transaction"
isLoading={ isLoading }
>
<Text>{ BigNumber(data.l1_gas_used).toFormat() }</Text>
</DetailsInfoItem>
) }
{ data.l1_gas_price && (
<DetailsInfoItem
title="L1 gas price"
hint="L1 gas price"
isLoading={ isLoading }
>
<Text mr={ 1 }>{ BigNumber(data.l1_gas_price).dividedBy(WEI).toFixed() } { currencyUnits.ether }</Text>
<Text variant="secondary">({ BigNumber(data.l1_gas_price).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })</Text>
</DetailsInfoItem>
) }
{ data.l1_fee && (
<DetailsInfoItem
title="L1 fee"
// eslint-disable-next-line max-len
hint={ `L1 Data Fee which is used to cover the L1 "security" cost from the batch submission mechanism. In combination with L2 execution fee, L1 fee makes the total amount of fees that a transaction pays.` }
isLoading={ isLoading }
>
<CurrencyValue
value={ data.l1_fee }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
/>
</DetailsInfoItem>
) }
{ data.l1_fee_scalar && (
<DetailsInfoItem
title="L1 fee scalar"
hint="A Dynamic overhead (fee scalar) premium, which serves as a buffer in case L1 prices rapidly increase."
isLoading={ isLoading }
>
<Text>{ data.l1_fee_scalar }</Text>
</DetailsInfoItem>
) }
</>
) }
<GridItem colSpan={{ base: undefined, lg: 2 }}>
<Element name="TxInfo__cutLink">
<Skeleton isLoaded={ !isLoading } mt={ 6 } display="inline-block">
<Link
display="inline-block"
fontSize="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
onClick={ handleCutClick }
>
{ isExpanded ? 'Hide details' : 'View details' }
</Link>
</Skeleton>
</Element>
</GridItem>
{ isExpanded && (
<>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
<TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position }/>
<DetailsInfoItem
title="Raw input"
hint="Binary data included with the transaction. See logs tab for additional info"
>
<RawInputData hex={ data.raw_input }/>
</DetailsInfoItem>
{ data.decoded_input && (
<DetailsInfoItem
title="Decoded input data"
hint="Decoded input data"
>
<LogDecodedInputData data={ data.decoded_input }/>
</DetailsInfoItem>
) }
</>
) }
</Grid>
);
};
export default TxInfo;
import { useBoolean } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
......@@ -9,45 +10,63 @@ import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { retry } from 'lib/api/useQueryClientConfig';
import { SECOND } from 'lib/consts';
import delay from 'lib/delay';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import { TX, TX_ZKEVM_L2 } from 'stubs/tx';
interface Params {
onTxStatusUpdate?: () => void;
updateDelay?: number;
txHash?: string;
export type TxQuery = UseQueryResult<Transaction, ResourceError<{ status: number }>> & {
socketStatus: 'close' | 'error' | undefined;
setRefetchOnError: {
on: () => void;
off: () => void;
toggle: () => void;
};
}
type ReturnType = UseQueryResult<Transaction, ResourceError<{ status: number }>> & {
socketStatus: 'close' | 'error' | undefined;
interface Params {
hash?: string;
isEnabled?: boolean;
}
export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay, txHash }: Params | undefined = {}): ReturnType {
export default function useTxQuery(params?: Params): TxQuery {
const [ socketStatus, setSocketStatus ] = React.useState<'close' | 'error'>();
const [ isRefetchEnabled, setRefetchEnabled ] = useBoolean(false);
const router = useRouter();
const queryClient = useQueryClient();
const [ socketStatus, setSocketStatus ] = React.useState<'close' | 'error'>();
const hash = txHash || getQueryParamString(router.query.hash);
const hash = params?.hash ?? getQueryParamString(router.query.hash);
const queryResult = useApiQuery<'tx', { status: number }>('tx', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash),
enabled: Boolean(hash) && params?.isEnabled !== false,
refetchOnMount: false,
placeholderData: config.features.zkEvmRollup.isEnabled ? TX_ZKEVM_L2 : TX,
retry: (failureCount, error) => {
if (isRefetchEnabled) {
return false;
}
return retry(failureCount, error);
},
refetchInterval: (): number | false => {
return isRefetchEnabled ? 15 * SECOND : false;
},
},
});
const { data, isError, isPending } = queryResult;
const { data, isError, isPlaceholderData, isPending } = queryResult;
const handleStatusUpdateMessage: SocketMessage.TxStatusUpdate['handler'] = React.useCallback(async() => {
updateDelay && await delay(updateDelay);
await delay(5 * SECOND);
queryClient.invalidateQueries({
queryKey: getResourceKey('tx', { pathParams: { hash } }),
});
onTxStatusUpdate?.();
}, [ onTxStatusUpdate, queryClient, hash, updateDelay ]);
}, [ queryClient, hash ]);
const handleSocketClose = React.useCallback(() => {
setSocketStatus('close');
......@@ -61,7 +80,7 @@ export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay, txHash }
topic: `transactions:${ hash }`,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: isPending || isError || data.status !== null,
isDisabled: isPending || isPlaceholderData || isError || data.status !== null,
});
useSocketMessage({
channel,
......@@ -69,8 +88,9 @@ export default function useFetchTxInfo({ onTxStatusUpdate, updateDelay, txHash }
handler: handleStatusUpdateMessage,
});
return {
return React.useMemo(() => ({
...queryResult,
socketStatus,
};
setRefetchOnError: setRefetchEnabled,
}), [ queryResult, socketStatus, setRefetchEnabled ]);
}
......@@ -11,6 +11,7 @@ import type { ResourceError } from 'lib/api/resources';
import { WEI, WEI_IN_GWEI } from 'lib/consts';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
......@@ -116,7 +117,7 @@ const UserOpDetails = ({ query }: Props) => {
>
<CurrencyValue
value={ data.fee }
currency={ config.chain.currency.symbol }
currency={ currencyUnits.ether }
isLoading={ isPlaceholderData }
/>
</DetailsInfoItem>
......@@ -212,17 +213,17 @@ const UserOpDetails = ({ query }: Props) => {
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>{ BigNumber(data.max_fee_per_gas).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } Gwei)
{ space }({ BigNumber(data.max_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text>
</DetailsInfoItem><DetailsInfoItem
title="Max priority fee per gas"
hint="Maximum priority fee per gas"
>
<Text>{ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI).toFixed() } { config.chain.currency.symbol } </Text>
<Text>{ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } Gwei)
{ space }({ BigNumber(data.max_priority_fee_per_gas).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text>
</DetailsInfoItem>
</>
......
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