beacon deposits (#2947)

* beacon deposits start

* some fixes

* test

* counters

* conflict fix

* fix mock
This commit is contained in:
Igor Stuev
2025-08-22 12:11:54 +02:00
committed by GitHub
parent 8638b62d6c
commit 5416a636a4
36 changed files with 896 additions and 5 deletions
+3 -1
View File
@@ -4,8 +4,9 @@ import { getEnvValue } from '../utils';
const title = 'Beacon chain';
const config: Feature<{ currency: { symbol: string } }> = (() => {
const config: Feature<{ currency: { symbol: string }; validatorUrlTemplate: string | undefined }> = (() => {
if (getEnvValue('NEXT_PUBLIC_HAS_BEACON_CHAIN') === 'true') {
const validatorUrlTemplate = getEnvValue('NEXT_PUBLIC_BEACON_CHAIN_VALIDATOR_URL_TEMPLATE');
return Object.freeze({
title,
isEnabled: true,
@@ -15,6 +16,7 @@ const config: Feature<{ currency: { symbol: string } }> = (() => {
getEnvValue('NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL') ||
'', // maybe we need some other default value here
},
validatorUrlTemplate,
});
}
+1
View File
@@ -27,6 +27,7 @@ NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-c
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0xbf69c7abc4fee283b59a9633dadfdaedde5c5ee0fba3e80a08b5b8a3acbd4363
NEXT_PUBLIC_HAS_BEACON_CHAIN=true
NEXT_PUBLIC_BEACON_CHAIN_VALIDATOR_URL_TEMPLATE=https://light-sepolia.beaconcha.in/validator/{pk}
NEXT_PUBLIC_HAS_USER_OPS=true
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['rgba(51, 53, 67, 1)'],'text_color':['rgba(165, 252, 122, 1)']}
+7
View File
@@ -193,6 +193,13 @@ const beaconChainSchema = yup
'NEXT_PUBLIC_BEACON_CHAIN_CURRENCY_SYMBOL cannot not be used if NEXT_PUBLIC_HAS_BEACON_CHAIN is not set to "true"',
),
}),
NEXT_PUBLIC_BEACON_CHAIN_VALIDATOR_URL_TEMPLATE: yup
.string()
.when('NEXT_PUBLIC_HAS_BEACON_CHAIN', {
is: (value: boolean) => value,
then: (schema) => schema,
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_BEACON_CHAIN_VALIDATOR_URL_TEMPLATE cannot not be used if NEXT_PUBLIC_HAS_BEACON_CHAIN is not set to "true"'),
}),
});
const tacSchema = yup
+1
View File
@@ -482,6 +482,7 @@ Ads are enabled by default on all self-hosted instances. If you would like to di
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_HAS_BEACON_CHAIN | `boolean` | Set to true for networks with the beacon chain | Required | - | `true` | v1.0.x+ |
| NEXT_PUBLIC_BEACON_CHAIN_CURRENCY_SYMBOL | `string` | Beacon network currency symbol | - | `NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL` | `ETH` | v1.0.x+ |
| NEXT_PUBLIC_BEACON_CHAIN_VALIDATOR_URL_TEMPLATE | `string` | Url template to build a link to validator. Should contain `{pk}` string that will be replaced with the validator's public key | - | - | `https://example.com/beacon/{pk}/validator` | v2.3.0+ |
&nbsp;
+8
View File
@@ -21,6 +21,7 @@ import type {
AddressNFTTokensFilter,
} from 'types/api/address';
import type { AddressesMetadataSearchFilters, AddressesMetadataSearchResult, AddressesResponse } from 'types/api/addresses';
import type { DepositsResponse } from 'types/api/deposits';
import type { LogsResponseAddress } from 'types/api/log';
import type { TransactionsSorting } from 'types/api/transaction';
@@ -109,6 +110,12 @@ export const GENERAL_API_ADDRESS_RESOURCES = {
filterFields: [ 'type' as const ],
paginated: true,
},
address_deposits: {
path: '/api/v2/addresses/:hash/beacon/deposits',
pathParams: [ 'hash' as const ],
filterFields: [],
paginated: true,
},
address_withdrawals: {
path: '/api/v2/addresses/:hash/withdrawals',
pathParams: [ 'hash' as const ],
@@ -174,6 +181,7 @@ R extends 'general:address_tokens' ? AddressTokensResponse :
R extends 'general:address_nfts' ? AddressNFTsResponse :
R extends 'general:address_collections' ? AddressCollectionsResponse :
R extends 'general:address_withdrawals' ? AddressWithdrawalsResponse :
R extends 'general:address_deposits' ? DepositsResponse :
R extends 'general:address_epoch_rewards' ? AddressEpochRewardsResponse :
R extends 'general:address_xstar_score' ? AddressXStarResponse :
R extends 'general:address_3rd_party_info' ? unknown :
+8
View File
@@ -8,6 +8,7 @@ import type {
BlockCountdownResponse,
BlockInternalTransactionsResponse,
} from 'types/api/block';
import type { DepositsResponse } from 'types/api/deposits';
import type { TTxsWithBlobsFilters } from 'types/api/txsFilters';
export const GENERAL_API_BLOCK_RESOURCES = {
@@ -31,6 +32,12 @@ export const GENERAL_API_BLOCK_RESOURCES = {
pathParams: [ 'height_or_hash' as const ],
paginated: true,
},
block_deposits: {
path: '/api/v2/blocks/:height_or_hash/beacon/deposits',
pathParams: [ 'height_or_hash' as const ],
filterFields: [],
paginated: true,
},
block_withdrawals: {
path: '/api/v2/blocks/:height_or_hash/withdrawals',
pathParams: [ 'height_or_hash' as const ],
@@ -49,6 +56,7 @@ R extends 'general:block_countdown' ? BlockCountdownResponse :
R extends 'general:block_txs' ? BlockTransactionsResponse :
R extends 'general:block_internal_txs' ? BlockInternalTransactionsResponse :
R extends 'general:block_withdrawals' ? BlockWithdrawalsResponse :
R extends 'general:block_deposits' ? DepositsResponse :
never;
/* eslint-enable @stylistic/indent */
+13
View File
@@ -8,6 +8,7 @@ import type { Blob } from 'types/api/blobs';
import type { Block } from 'types/api/block';
import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig, CeloConfig, CsvExportConfig } from 'types/api/configs';
import type { DepositsResponse, DepositsCounters } from 'types/api/deposits';
import type { CeloEpochDetails, CeloEpochElectionRewardDetailsResponse, CeloEpochListResponse } from 'types/api/epochs';
import type { IndexingStatus } from 'types/api/indexingStatus';
import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves';
@@ -48,6 +49,16 @@ export const GENERAL_API_MISC_RESOURCES = {
path: '/api/v2/withdrawals/counters',
},
// DEPOSITS
deposits: {
path: '/api/v2/beacon/deposits',
filterFields: [],
paginated: true,
},
deposits_counters: {
path: '/api/v2/beacon/deposits/count',
},
// APP STATS
stats: {
path: '/api/v2/stats',
@@ -296,6 +307,8 @@ R extends 'general:noves_address_history' ? NovesAccountHistoryResponse :
R extends 'general:noves_describe_txs' ? NovesDescribeTxsResponse :
R extends 'general:withdrawals' ? WithdrawalsResponse :
R extends 'general:withdrawals_counters' ? WithdrawalsCounters :
R extends 'general:deposits' ? DepositsResponse :
R extends 'general:deposits_counters' ? DepositsCounters :
R extends 'general:advanced_filter' ? AdvancedFilterResponse :
R extends 'general:advanced_filter_methods' ? AdvancedFilterMethodsResponse :
never;
+6
View File
@@ -207,6 +207,12 @@ export default function useNavItems(): ReturnType {
validators,
verifiedContracts,
ensLookup,
config.features.beaconChain.isEnabled && {
text: 'Deposits',
nextRoute: { pathname: '/deposits' as const },
icon: 'arrows/south-east',
isActive: pathname === '/deposits',
},
config.features.beaconChain.isEnabled && {
text: 'Withdrawals',
nextRoute: { pathname: '/withdrawals' as const },
+1 -1
View File
@@ -39,7 +39,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/txn-withdrawals': '%network_name% L2 to L1 message relayer',
'/visualize/sol2uml': '%network_name% Solidity UML diagram',
'/csv-export': '%network_name% export data to CSV',
'/deposits': '%network_name% deposits (L1 > L2)',
'/deposits': '%network_name% deposits - track on %network_name% explorer',
'/output-roots': '%network_name% output roots',
'/dispute-games': '%network_name% dispute games',
'/batches': '%network_name% txn batches',
+1 -1
View File
@@ -37,7 +37,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/txn-withdrawals': 'Txn withdrawals',
'/visualize/sol2uml': 'Solidity UML diagram',
'/csv-export': 'Export data to CSV file',
'/deposits': 'Deposits (L1 > L2)',
'/deposits': 'Deposits',
'/output-roots': 'Output roots',
'/dispute-games': 'Dispute games',
'/batches': 'Txn batches',
+1
View File
@@ -8,4 +8,5 @@ export const base: AddressTabsCounters = {
transactions_count: 51,
validations_count: 42,
withdrawals_count: 11,
beacon_deposits_count: 10,
};
+86
View File
@@ -0,0 +1,86 @@
import type { AddressParam } from 'types/api/addressParams';
import type { DepositsResponse } from 'types/api/deposits';
export const data: DepositsResponse = {
items: [
{
amount: '192175000000000',
block_number: 43242,
index: 11688,
pubkey: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
signature: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
status: 'completed',
from_address: {
hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
implementations: null,
is_contract: false,
is_verified: null,
name: null,
} as AddressParam,
block_hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
block_timestamp: '2022-06-07T18:12:24.000000Z',
transaction_hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
withdrawal_address: {
hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
implementations: null,
is_contract: false,
is_verified: null,
name: null,
} as AddressParam,
},
{
amount: '192175000000000',
block_number: 43242,
index: 11687,
pubkey: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
signature: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
status: 'pending',
from_address: {
hash: '0xf97e987c050e5Ab072211Ad2C213Eb5AEE4DF134',
implementations: null,
is_contract: false,
is_verified: null,
name: null,
} as AddressParam,
block_hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
block_timestamp: '2022-05-07T18:12:24.000000Z',
transaction_hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
withdrawal_address: {
hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
implementations: null,
is_contract: false,
is_verified: null,
name: null,
} as AddressParam,
},
{
amount: '182773000000000',
block_number: 43242,
index: 11686,
pubkey: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
signature: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
status: 'invalid',
from_address: {
hash: '0xf97e123c050e5Ab072211Ad2C213Eb5AEE4DF134',
implementations: null,
is_contract: false,
is_verified: null,
name: null,
} as AddressParam,
block_hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
block_timestamp: '2022-04-07T18:12:24.000000Z',
transaction_hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
withdrawal_address: {
hash: '0xf97e180c050e5Ab072211Ad2C213Eb5AEE4DF134',
implementations: null,
is_contract: false,
is_verified: null,
name: null,
} as AddressParam,
},
],
next_page_params: {
index: 11639,
items_count: 50,
},
};
+3 -1
View File
@@ -184,7 +184,9 @@ export const rollup: Guard = (chainConfig: typeof config) => async() => {
const DEPOSITS_ROLLUP_TYPES: Array<RollupType> = [ 'optimistic', 'shibarium', 'zkEvm', 'arbitrum', 'scroll' ];
export const deposits: Guard = (chainConfig: typeof config) => async() => {
const rollupFeature = chainConfig.features.rollup;
if (!(rollupFeature.isEnabled && DEPOSITS_ROLLUP_TYPES.includes(rollupFeature.type))) {
if (
!chainConfig.features.beaconChain.isEnabled &&
!(rollupFeature.isEnabled && DEPOSITS_ROLLUP_TYPES.includes(rollupFeature.type))) {
return {
notFound: true,
};
+5
View File
@@ -6,6 +6,7 @@ import PageNextJs from 'nextjs/PageNextJs';
import config from 'configs/app';
const rollupFeature = config.features.rollup;
const beaconChainFeature = config.features.beaconChain;
const Deposits = dynamic(() => {
if (rollupFeature.isEnabled && rollupFeature.type === 'optimistic') {
@@ -28,6 +29,10 @@ const Deposits = dynamic(() => {
return import('ui/pages/ScrollL2Deposits');
}
if (beaconChainFeature.isEnabled) {
return import('ui/pages/BeaconChainDeposits');
}
throw new Error('Deposits feature is not enabled.');
}, { ssr: false });
+1
View File
@@ -68,6 +68,7 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
],
beaconChain: [
[ 'NEXT_PUBLIC_HAS_BEACON_CHAIN', 'true' ],
[ 'NEXT_PUBLIC_BEACON_CHAIN_VALIDATOR_URL_TEMPLATE', 'https://beaconcha.in/validator/{pk}' ],
],
txInterpretation: [
[ 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', 'blockscout' ],
+1
View File
@@ -55,6 +55,7 @@ export const ADDRESS_TABS_COUNTERS: AddressTabsCounters = {
transactions_count: 10,
validations_count: 10,
withdrawals_count: 10,
beacon_deposits_count: 10,
};
export const TOP_ADDRESS: AddressesItem = {
+18
View File
@@ -0,0 +1,18 @@
import type { DepositsItem } from 'types/api/deposits';
import { ADDRESS_PARAMS } from './addressParams';
import { TX_HASH } from './tx';
export const DEPOSIT: DepositsItem = {
amount: '12565723',
index: 1,
block_number: 1231111111,
block_hash: '0x1234567890',
block_timestamp: '2023-05-12T19:29:12.000000Z',
pubkey: '0x1234567890123456789012345678901234567890',
status: 'pending',
from_address: ADDRESS_PARAMS,
transaction_hash: TX_HASH,
withdrawal_address: ADDRESS_PARAMS,
signature: '0x1234567890123456789012345678901234567890',
};
+1
View File
@@ -201,6 +201,7 @@ export type AddressTabsCounters = {
transactions_count: number | null;
validations_count: number | null;
withdrawals_count: number | null;
beacon_deposits_count: number | null;
celo_election_rewards_count?: number | null;
};
+1
View File
@@ -44,6 +44,7 @@ export interface Block {
transaction_fees: string | null;
uncles_hashes: Array<string>;
withdrawals_count?: number;
beacon_deposits_count?: number;
// ROOTSTOCK FIELDS
bitcoin_merged_mining_coinbase_transaction?: string | null;
bitcoin_merged_mining_header?: string | null;
+29
View File
@@ -0,0 +1,29 @@
import type { AddressParam } from './addressParams';
export type DepositStatus = 'pending' | 'invalid' | 'completed';
export type DepositsResponse = {
items: Array<DepositsItem>;
next_page_params: {
index: number;
items_count: number;
} | null;
};
export type DepositsItem = {
amount: string;
block_number: number;
block_hash: string;
block_timestamp: string;
index: number;
pubkey: string;
signature: string;
status: DepositStatus;
from_address: AddressParam;
transaction_hash: string;
withdrawal_address: AddressParam;
};
export type DepositsCounters = {
deposits_count: string;
};
+83
View File
@@ -0,0 +1,83 @@
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import useIsMounted from 'lib/hooks/useIsMounted';
import getQueryParamString from 'lib/router/getQueryParamString';
import { DEPOSIT } from 'stubs/deposits';
import { generateListStub } from 'stubs/utils';
import BeaconChainDepositsListItem from 'ui/deposits/beaconChain/BeaconChainDepositsListItem';
import BeaconChainDepositsTable from 'ui/deposits/beaconChain/BeaconChainDepositsTable';
import ActionBar, { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
type Props = {
shouldRender?: boolean;
isQueryEnabled?: boolean;
};
const AddressDeposits = ({ shouldRender = true, isQueryEnabled = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const hash = getQueryParamString(router.query.hash);
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'general:address_deposits',
pathParams: { hash },
options: {
enabled: isQueryEnabled,
placeholderData: generateListStub<'general:address_deposits'>(DEPOSIT, 50, { next_page_params: {
index: 5,
items_count: 50,
} }),
},
});
if (!isMounted || !shouldRender) {
return null;
}
const content = data?.items ? (
<>
<Box hideFrom="lg">
{ data.items.map((item, index) => (
<BeaconChainDepositsListItem
key={ item.index + Number(isPlaceholderData ? index : '') }
item={ item }
view="address"
isLoading={ isPlaceholderData }
/>
)) }
</Box>
<Box hideBelow="lg">
<BeaconChainDepositsTable
items={ data.items }
view="address"
top={ pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
isLoading={ isPlaceholderData }
/>
</Box>
</>
) : null ;
const actionBar = pagination.isVisible ? (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) : null;
return (
<DataListDisplay
isError={ isError }
itemsNum={ data?.items?.length }
emptyText="There are no deposits for this address."
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
);
};
export default AddressDeposits;
+46
View File
@@ -0,0 +1,46 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import BeaconChainDepositsList from 'ui/deposits/beaconChain/BeaconChainDepositsList';
import BeaconChainDepositsTable from 'ui/deposits/beaconChain/BeaconChainDepositsTable';
import DataListDisplay from 'ui/shared/DataListDisplay';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
type Props = {
blockDepositsQuery: QueryWithPagesResult<'general:block_deposits'>;
};
const TABS_HEIGHT = 88;
const BlockDeposits = ({ blockDepositsQuery }: Props) => {
const content = blockDepositsQuery.data?.items ? (
<>
<Box hideFrom="lg">
<BeaconChainDepositsList
items={ blockDepositsQuery.data.items }
isLoading={ blockDepositsQuery.isPlaceholderData }
view="block"
/>
</Box>
<Box hideBelow="lg">
<BeaconChainDepositsTable
items={ blockDepositsQuery.data.items }
isLoading={ blockDepositsQuery.isPlaceholderData }
top={ blockDepositsQuery.pagination.isVisible ? TABS_HEIGHT : 0 }
view="block"
/>
</Box>
</>
) : null ;
return (
<DataListDisplay
isError={ blockDepositsQuery.isError }
itemsNum={ blockDepositsQuery.data?.items?.length }
emptyText="There are no deposits for this block."
>
{ content }
</DataListDisplay>
);
};
export default BlockDeposits;
+41
View File
@@ -0,0 +1,41 @@
import config from 'configs/app';
import { DEPOSIT } from 'stubs/deposits';
import { generateListStub } from 'stubs/utils';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import type { BlockQuery } from './useBlockQuery';
export type BlockDepositsQuery = QueryWithPagesResult<'general:block_deposits'> & {
isDegradedData: boolean;
};
interface Params {
heightOrHash: string;
blockQuery: BlockQuery;
tab: string;
}
// No deposits data in RPC, so we use API only
export default function useBlockDepositsQuery({ heightOrHash, blockQuery, tab }: Params): BlockDepositsQuery {
const apiQuery = useQueryWithPages({
resourceName: 'general:block_deposits',
pathParams: { height_or_hash: heightOrHash },
options: {
enabled:
tab === 'deposits' &&
config.features.beaconChain.isEnabled &&
!blockQuery.isPlaceholderData && !blockQuery.isDegradedData,
placeholderData: generateListStub<'general:block_deposits'>(DEPOSIT, 50, { next_page_params: {
index: 5,
items_count: 50,
} }),
refetchOnMount: false,
},
});
return {
...apiQuery,
isDegradedData: false,
};
}
@@ -0,0 +1,39 @@
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { DepositsItem } from 'types/api/deposits';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import BeaconChainDepositsListItem from './BeaconChainDepositsListItem';
type Props = {
isLoading?: boolean;
} & ({
items: Array<DepositsItem>;
view: 'list' | 'block' | 'address';
});
const DepositsList = ({ items, view, isLoading }: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(items, !isLoading);
return (
<Box>
{ items.slice(0, renderedItemsNum).map((item, index) => {
const key = item.index + (isLoading ? String(index) : '');
return (
<BeaconChainDepositsListItem
key={ key }
item={ item as DepositsItem }
view={ view }
isLoading={ isLoading }
/>
);
}) }
<div ref={ cutRef }/>
</Box>
);
};
export default React.memo(DepositsList);
@@ -0,0 +1,97 @@
import React from 'react';
import type { DepositsItem } from 'types/api/deposits';
import config from 'configs/app';
import { currencyUnits } from 'lib/units';
import BeaconChainDepositSignature from 'ui/shared/beacon/BeaconChainDepositSignature';
import BeaconChainDepositStatusTag from 'ui/shared/beacon/BeaconChainDepositStatusTag';
import BeaconChainValidatorLink from 'ui/shared/beacon/BeaconChainValidatorLink';
import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
const feature = config.features.beaconChain;
type Props = {
item: DepositsItem;
view: 'list' | 'address' | 'block';
isLoading?: boolean;
};
const BeaconChainDepositsListItem = ({ item, isLoading, view }: Props) => {
if (!feature.isEnabled) {
return null;
}
return (
<ListItemMobileGrid.Container gridTemplateColumns="120px auto">
<ListItemMobileGrid.Label isLoading={ isLoading }>Transaction hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TxEntity hash={ item.transaction_hash } isLoading={ isLoading } truncation="constant_long"/>
</ListItemMobileGrid.Value>
{ view !== 'block' && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Block</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlockEntity
number={ item.block_number }
hash={ item.block_hash }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TimeWithTooltip
timestamp={ item.block_timestamp }
isLoading={ isLoading }
display="inline-block"
/>
</ListItemMobileGrid.Value>
</>
) }
<ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<CurrencyValue value={ item.amount } currency={ currencyUnits.ether } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
{ view !== 'address' && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>From</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<AddressEntity
address={ item.from_address }
isLoading={ isLoading }
truncation="constant"
/>
</ListItemMobileGrid.Value>
</>
) }
<ListItemMobileGrid.Label isLoading={ isLoading }>PubKey</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BeaconChainValidatorLink pubkey={ item.pubkey } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Signature</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BeaconChainDepositSignature signature={ item.signature } isLoading={ Boolean(isLoading) }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BeaconChainDepositStatusTag status={ item.status } isLoading={ Boolean(isLoading) }/>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default BeaconChainDepositsListItem;
@@ -0,0 +1,52 @@
import React from 'react';
import type { DepositsItem } from 'types/api/deposits';
import config from 'configs/app';
import useLazyRenderedList from 'lib/hooks/useLazyRenderedList';
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
import BeaconChainDepositsTableItem from './BeaconChainDepositsTableItem';
const feature = config.features.beaconChain;
type Props = {
top: number;
isLoading?: boolean;
items: Array<DepositsItem>;
view: 'list' | 'address' | 'block';
};
const BeaconChainDepositsTable = ({ items, isLoading, top, view }: Props) => {
const { cutRef, renderedItemsNum } = useLazyRenderedList(items, !isLoading);
if (!feature.isEnabled) {
return null;
}
return (
<TableRoot style={{ tableLayout: 'auto' }} minW="950px">
<TableHeaderSticky top={ top }>
<TableRow>
<TableColumnHeader>Transaction hash</TableColumnHeader>
{ view !== 'block' && <TableColumnHeader>Block</TableColumnHeader> }
{ view !== 'block' && <TableColumnHeader>Timestamp<TimeFormatToggle/></TableColumnHeader> }
<TableColumnHeader>{ `Value ${ feature.currency.symbol }` }</TableColumnHeader>
{ view !== 'address' && <TableColumnHeader>From</TableColumnHeader> }
<TableColumnHeader>PubKey</TableColumnHeader>
<TableColumnHeader>Signature</TableColumnHeader>
<TableColumnHeader>Status</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ items.slice(0, renderedItemsNum).map((item, index) => (
<BeaconChainDepositsTableItem key={ item.index + (isLoading ? String(index) : '') } item={ item } view={ view } isLoading={ isLoading }/>
)) }
<TableRow ref={ cutRef }/>
</TableBody>
</TableRoot>
);
};
export default BeaconChainDepositsTable;
@@ -0,0 +1,79 @@
import React from 'react';
import type { DepositsItem } from 'types/api/deposits';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import BeaconChainDepositSignature from 'ui/shared/beacon/BeaconChainDepositSignature';
import BeaconChainDepositStatusTag from 'ui/shared/beacon/BeaconChainDepositStatusTag';
import BeaconChainValidatorLink from 'ui/shared/beacon/BeaconChainValidatorLink';
import CurrencyValue from 'ui/shared/CurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
type Props = {
item: DepositsItem;
view: 'list' | 'address' | 'block';
isLoading?: boolean;
};
const BeaconChainDepositsTableItem = ({ item, view, isLoading }: Props) => {
return (
<TableRow>
<TableCell verticalAlign="middle">
<TxEntity
hash={ item.transaction_hash }
isLoading={ isLoading }
truncation="constant_long"
noIcon
noCopy={ false }
/>
</TableCell>
{ view !== 'block' && (
<TableCell verticalAlign="middle">
<BlockEntity
number={ item.block_number }
hash={ item.block_hash }
isLoading={ isLoading }
textStyle="sm"
noIcon
/>
</TableCell>
) }
{ view !== 'block' && (
<TableCell verticalAlign="middle">
<TimeWithTooltip
timestamp={ item.block_timestamp }
isLoading={ isLoading }
color="text.secondary"
display="inline-block"
/>
</TableCell>
) }
<TableCell verticalAlign="middle">
<CurrencyValue value={ item.amount } isLoading={ isLoading }/>
</TableCell>
{ view !== 'address' && (
<TableCell verticalAlign="middle">
<AddressEntity
address={ item.from_address }
isLoading={ isLoading }
truncation="constant"
/>
</TableCell>
) }
<TableCell verticalAlign="middle" maxW="200px" overflow="hidden">
<BeaconChainValidatorLink pubkey={ item.pubkey } isLoading={ isLoading }/>
</TableCell>
<TableCell verticalAlign="middle" maxW="200px" overflow="hidden">
<BeaconChainDepositSignature signature={ item.signature } isLoading={ Boolean(isLoading) }/>
</TableCell>
<TableCell verticalAlign="middle">
<BeaconChainDepositStatusTag status={ item.status } isLoading={ Boolean(isLoading) }/>
</TableCell>
</TableRow>
);
};
export default BeaconChainDepositsTableItem;
+9
View File
@@ -27,6 +27,7 @@ import AddressAccountHistory from 'ui/address/AddressAccountHistory';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance';
import AddressContract from 'ui/address/AddressContract';
import AddressDeposits from 'ui/address/AddressDeposits';
import AddressDetails from 'ui/address/AddressDetails';
import AddressEpochRewards from 'ui/address/AddressEpochRewards';
import AddressInternalTxs from 'ui/address/AddressInternalTxs';
@@ -224,6 +225,14 @@ const AddressPageContent = () => {
component: <AddressUserOps shouldRender={ !isTabsLoading } isQueryEnabled={ areQueriesEnabled }/>,
} :
undefined,
config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.beacon_deposits_count ?
{
id: 'deposits',
title: 'Deposits',
count: addressTabsCountersQuery.data?.beacon_deposits_count,
component: <AddressDeposits shouldRender={ !isTabsLoading } isQueryEnabled={ areQueriesEnabled }/>,
} :
undefined,
config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.withdrawals_count ?
{
id: 'withdrawals',
+28
View File
@@ -0,0 +1,28 @@
import React from 'react';
import { data as depositsData } from 'mocks/deposits/deposits';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect, devices } from 'playwright/lib';
import BeaconChainDeposits from './BeaconChainDeposits';
test('base view', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.beaconChain);
await mockTextAd();
await mockApiResponse('general:deposits', depositsData);
await mockApiResponse('general:deposits_counters', { deposits_count: '111111' });
const component = await render(<BeaconChainDeposits/>);
await expect(component).toHaveScreenshot();
});
test.describe('mobile', () => {
test.use({ viewport: devices['iPhone 13 Pro'].viewport });
test('base view', async({ render, mockEnvs, mockTextAd, mockApiResponse }) => {
await mockEnvs(ENVS_MAP.beaconChain);
await mockTextAd();
await mockApiResponse('general:deposits', depositsData);
await mockApiResponse('general:deposits_counters', { deposits_count: '111111' });
const component = await render(<BeaconChainDeposits/>);
await expect(component).toHaveScreenshot();
});
});
+98
View File
@@ -0,0 +1,98 @@
import { Box, Text } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { DEPOSIT } from 'stubs/deposits';
import { generateListStub } from 'stubs/utils';
import { Skeleton } from 'toolkit/chakra/skeleton';
import BeaconChainDepositsListItem from 'ui/deposits/beaconChain/BeaconChainDepositsListItem';
import BeaconChainDepositsTable from 'ui/deposits/beaconChain/BeaconChainDepositsTable';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
const feature = config.features.beaconChain;
const Deposits = () => {
const { data, isError, isPlaceholderData, pagination } = useQueryWithPages({
resourceName: 'general:deposits',
options: {
placeholderData: generateListStub<'general:deposits'>(DEPOSIT, 50, { next_page_params: {
index: 5,
items_count: 50,
} }),
},
});
const countersQuery = useApiQuery('general:deposits_counters', {
queryOptions: {
placeholderData: {
deposits_count: '19091878',
},
},
});
const content = data?.items ? (
<>
<Box hideFrom="lg">
{ data.items.map(((item, index) => (
<BeaconChainDepositsListItem
key={ item.index + (isPlaceholderData ? String(index) : '') }
item={ item }
view="list"
isLoading={ isPlaceholderData }
/>
))) }
</Box>
<Box hideBelow="lg">
<BeaconChainDepositsTable
items={ data.items }
view="list"
top={ pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
isLoading={ isPlaceholderData }
/>
</Box>
</>
) : null;
const text = (() => {
if (countersQuery.isError || !feature.isEnabled) {
return null;
}
return (
<Skeleton loading={ countersQuery.isPlaceholderData || isPlaceholderData } display="flex" flexWrap="wrap">
{ countersQuery.data && (
<Text lineHeight={{ base: '24px', lg: '32px' }}>
{ BigNumber(countersQuery.data.deposits_count).toFormat() } deposits processed
</Text>
) }
</Skeleton>
);
})();
const actionBar = <StickyPaginationWithText text={ text } pagination={ pagination }/>;
return (
<>
<PageTitle
title={ config.meta.seo.enhancedDataEnabled ? `${ config.chain.name } deposits` : 'Deposits' }
withTextAd
/>
<DataListDisplay
isError={ isError }
itemsNum={ data?.items.length }
emptyText="There are no deposits."
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
</>
);
};
export default Deposits;
+18 -1
View File
@@ -18,10 +18,12 @@ import getQueryParamString from 'lib/router/getQueryParamString';
import { Skeleton } from 'toolkit/chakra/skeleton';
import RoutedTabs from 'toolkit/components/RoutedTabs/RoutedTabs';
import BlockCeloEpochTag from 'ui/block/BlockCeloEpochTag';
import BlockDeposits from 'ui/block/BlockDeposits';
import BlockDetails from 'ui/block/BlockDetails';
import BlockInternalTxs from 'ui/block/BlockInternalTxs';
import BlockWithdrawals from 'ui/block/BlockWithdrawals';
import useBlockBlobTxsQuery from 'ui/block/useBlockBlobTxsQuery';
import useBlockDepositsQuery from 'ui/block/useBlockDepositsQuery';
import useBlockInternalTxsQuery from 'ui/block/useBlockInternalTxsQuery';
import useBlockQuery from 'ui/block/useBlockQuery';
import useBlockTxsQuery from 'ui/block/useBlockTxsQuery';
@@ -53,12 +55,14 @@ const BlockPageContent = () => {
const blockQuery = useBlockQuery({ heightOrHash });
const blockTxsQuery = useBlockTxsQuery({ heightOrHash, blockQuery, tab });
const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab });
const blockDepositsQuery = useBlockDepositsQuery({ heightOrHash, blockQuery, tab });
const blockBlobTxsQuery = useBlockBlobTxsQuery({ heightOrHash, blockQuery, tab });
const blockInternalTxsQuery = useBlockInternalTxsQuery({ heightOrHash, blockQuery, tab });
const hasPagination = !isMobile && (
(tab === 'txs' && blockTxsQuery.pagination.isVisible) ||
(tab === 'withdrawals' && blockWithdrawalsQuery.pagination.isVisible) ||
(tab === 'deposits' && blockDepositsQuery.pagination.isVisible) ||
(tab === 'internal_txs' && blockInternalTxsQuery.pagination.isVisible)
);
@@ -101,6 +105,17 @@ const BlockPageContent = () => {
<TxsWithFrontendSorting query={ blockBlobTxsQuery } showBlockInfo={ false }/>
),
} : null,
config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.beacon_deposits_count) ?
{
id: 'deposits',
title: 'Deposits',
component: (
<>
{ blockDepositsQuery.isDegradedData && <ServiceDegradationWarning isLoading={ blockDepositsQuery.isPlaceholderData } mb={ 6 }/> }
<BlockDeposits blockDepositsQuery={ blockDepositsQuery }/>
</>
),
} : null,
config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.withdrawals_count) ?
{
id: 'withdrawals',
@@ -112,13 +127,15 @@ const BlockPageContent = () => {
</>
),
} : null,
].filter(Boolean)), [ blockBlobTxsQuery, blockInternalTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery, hasPagination ]);
].filter(Boolean)), [ blockBlobTxsQuery, blockDepositsQuery, blockInternalTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery, hasPagination ]);
let pagination;
if (tab === 'txs') {
pagination = blockTxsQuery.pagination;
} else if (tab === 'withdrawals') {
pagination = blockWithdrawalsQuery.pagination;
} else if (tab === 'deposits') {
pagination = blockDepositsQuery.pagination;
} else if (tab === 'internal_txs') {
pagination = blockInternalTxsQuery.pagination;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

@@ -0,0 +1,20 @@
import { chakra, Text } from '@chakra-ui/react';
import React from 'react';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TruncatedTextTooltip } from 'toolkit/components/truncation/TruncatedTextTooltip';
import CopyToClipboard from '../CopyToClipboard';
const BeaconChainDepositSignature = ({ signature, isLoading }: { signature: string; isLoading: boolean }) => {
return (
<Skeleton loading={ isLoading } display="grid" gridTemplateColumns="1fr 24px" overflow="hidden">
<TruncatedTextTooltip label={ signature }>
<Text overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">{ signature }</Text>
</TruncatedTextTooltip>
<CopyToClipboard text={ signature }/>
</Skeleton>
);
};
export default React.memo(chakra(BeaconChainDepositSignature));
@@ -0,0 +1,25 @@
import { capitalize } from 'es-toolkit';
import React from 'react';
import type { DepositsItem } from 'types/api/deposits';
import StatusTag from '../statusTag/StatusTag';
const BeaconChainDepositStatusTag = ({ status, isLoading }: { status: DepositsItem['status']; isLoading: boolean }) => {
const statusValue = (() => {
switch (status) {
case 'pending':
return 'pending';
case 'completed':
return 'ok';
case 'invalid':
return 'error';
default:
return 'pending';
}
})();
return <StatusTag type={ statusValue } text={ capitalize(status) } loading={ isLoading }/>;
};
export default BeaconChainDepositStatusTag;
@@ -0,0 +1,66 @@
import { Text } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { TruncatedTextTooltip } from 'toolkit/components/truncation/TruncatedTextTooltip';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
const feature = config.features.beaconChain;
const BeaconChainValidatorLink = ({ pubkey, isLoading }: { pubkey: string; isLoading?: boolean }) => {
if (!feature.isEnabled) {
return null;
}
let content;
if (!feature.validatorUrlTemplate) {
content = (
<Text
display="inline-block"
textOverflow="ellipsis"
overflow="hidden"
whiteSpace="nowrap"
maxW="100%"
>
{ pubkey }
</Text>
);
} else {
content = (
<Link
href={ feature.validatorUrlTemplate.replace('{pk}', pubkey) }
external
loading={ isLoading }
overflow="hidden"
display="grid"
gridTemplateColumns="auto 20px"
>
<TruncatedTextTooltip label={ pubkey }>
<Text overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">{ pubkey }</Text>
</TruncatedTextTooltip>
</Link>
);
}
return (
<Skeleton
display="grid"
overflow="hidden"
gridTemplateColumns="auto 24px"
alignItems="center"
loading={ isLoading }
>
{ content }
<CopyToClipboard
text={ pubkey }
type="text"
isLoading={ isLoading }
ml={ 1 }
/>
</Skeleton>
);
};
export default React.memo(BeaconChainValidatorLink);