diff --git a/configs/app/features/beaconChain.ts b/configs/app/features/beaconChain.ts index a29540b756..3e9695482f 100644 --- a/configs/app/features/beaconChain.ts +++ b/configs/app/features/beaconChain.ts @@ -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, }); } diff --git a/configs/envs/.env.eth_sepolia b/configs/envs/.env.eth_sepolia index da5124d39c..26ea58f9a5 100644 --- a/configs/envs/.env.eth_sepolia +++ b/configs/envs/.env.eth_sepolia @@ -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)']} diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index f2dd9f5a96..e9e9b57426 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -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 diff --git a/docs/ENVS.md b/docs/ENVS.md index da82906069..6c5643e62a 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -480,6 +480,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+ |   diff --git a/lib/api/services/general/address.ts b/lib/api/services/general/address.ts index 7fb6031138..8a3a468293 100644 --- a/lib/api/services/general/address.ts +++ b/lib/api/services/general/address.ts @@ -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 : diff --git a/lib/api/services/general/block.ts b/lib/api/services/general/block.ts index 9196505719..c8167f9970 100644 --- a/lib/api/services/general/block.ts +++ b/lib/api/services/general/block.ts @@ -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 */ diff --git a/lib/api/services/general/misc.ts b/lib/api/services/general/misc.ts index c29410d219..d518a2ab34 100644 --- a/lib/api/services/general/misc.ts +++ b/lib/api/services/general/misc.ts @@ -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; diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index 87d63d5a21..4972ed9fe4 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -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 }, diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index 24bca51ddc..fcb9376d65 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -39,7 +39,7 @@ const TEMPLATE_MAP: Record = { '/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', diff --git a/lib/mixpanel/getPageType.ts b/lib/mixpanel/getPageType.ts index 32af5d8192..0f41dcbd11 100644 --- a/lib/mixpanel/getPageType.ts +++ b/lib/mixpanel/getPageType.ts @@ -37,7 +37,7 @@ export const PAGE_TYPE_DICT: Record = { '/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', diff --git a/mocks/address/tabCounters.ts b/mocks/address/tabCounters.ts index 85c3cd1e9a..27864a3ad3 100644 --- a/mocks/address/tabCounters.ts +++ b/mocks/address/tabCounters.ts @@ -8,4 +8,5 @@ export const base: AddressTabsCounters = { transactions_count: 51, validations_count: 42, withdrawals_count: 11, + beacon_deposits_count: 10, }; diff --git a/mocks/deposits/deposits.ts b/mocks/deposits/deposits.ts new file mode 100644 index 0000000000..64dfd3ada9 --- /dev/null +++ b/mocks/deposits/deposits.ts @@ -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, + }, +}; diff --git a/nextjs/getServerSideProps/guards.ts b/nextjs/getServerSideProps/guards.ts index 12e2ed4af6..105708b7cb 100644 --- a/nextjs/getServerSideProps/guards.ts +++ b/nextjs/getServerSideProps/guards.ts @@ -184,7 +184,9 @@ export const rollup: Guard = (chainConfig: typeof config) => async() => { const DEPOSITS_ROLLUP_TYPES: Array = [ '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, }; diff --git a/pages/deposits/index.tsx b/pages/deposits/index.tsx index 3ceda19501..e05534b6f9 100644 --- a/pages/deposits/index.tsx +++ b/pages/deposits/index.tsx @@ -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 }); diff --git a/playwright/fixtures/mockEnvs.ts b/playwright/fixtures/mockEnvs.ts index db325a9c7e..617e54f54f 100644 --- a/playwright/fixtures/mockEnvs.ts +++ b/playwright/fixtures/mockEnvs.ts @@ -68,6 +68,7 @@ export const ENVS_MAP: Record> = { ], 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' ], diff --git a/stubs/address.ts b/stubs/address.ts index 7931e552c9..7e6ede2c6a 100644 --- a/stubs/address.ts +++ b/stubs/address.ts @@ -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 = { diff --git a/stubs/deposits.ts b/stubs/deposits.ts new file mode 100644 index 0000000000..91b14505d0 --- /dev/null +++ b/stubs/deposits.ts @@ -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', +}; diff --git a/types/api/address.ts b/types/api/address.ts index 44d4ad9968..4dd1741a56 100644 --- a/types/api/address.ts +++ b/types/api/address.ts @@ -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; }; diff --git a/types/api/block.ts b/types/api/block.ts index 038a2e6004..6141a2f9a4 100644 --- a/types/api/block.ts +++ b/types/api/block.ts @@ -44,6 +44,7 @@ export interface Block { transaction_fees: string | null; uncles_hashes: Array; withdrawals_count?: number; + beacon_deposits_count?: number; // ROOTSTOCK FIELDS bitcoin_merged_mining_coinbase_transaction?: string | null; bitcoin_merged_mining_header?: string | null; diff --git a/types/api/deposits.ts b/types/api/deposits.ts new file mode 100644 index 0000000000..a7f3a97a89 --- /dev/null +++ b/types/api/deposits.ts @@ -0,0 +1,29 @@ +import type { AddressParam } from './addressParams'; + +export type DepositStatus = 'pending' | 'invalid' | 'completed'; + +export type DepositsResponse = { + items: Array; + 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; +}; diff --git a/ui/address/AddressDeposits.tsx b/ui/address/AddressDeposits.tsx new file mode 100644 index 0000000000..c2eb88f243 --- /dev/null +++ b/ui/address/AddressDeposits.tsx @@ -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 ? ( + <> + + { data.items.map((item, index) => ( + + )) } + + + + + + ) : null ; + + const actionBar = pagination.isVisible ? ( + + + + ) : null; + + return ( + + { content } + + ); +}; + +export default AddressDeposits; diff --git a/ui/block/BlockDeposits.tsx b/ui/block/BlockDeposits.tsx new file mode 100644 index 0000000000..2b993e92fb --- /dev/null +++ b/ui/block/BlockDeposits.tsx @@ -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 ? ( + <> + + + + + + + + ) : null ; + + return ( + + { content } + + ); +}; + +export default BlockDeposits; diff --git a/ui/block/useBlockDepositsQuery.tsx b/ui/block/useBlockDepositsQuery.tsx new file mode 100644 index 0000000000..91422327f7 --- /dev/null +++ b/ui/block/useBlockDepositsQuery.tsx @@ -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, + }; +} diff --git a/ui/deposits/beaconChain/BeaconChainDepositsList.tsx b/ui/deposits/beaconChain/BeaconChainDepositsList.tsx new file mode 100644 index 0000000000..d622fe1596 --- /dev/null +++ b/ui/deposits/beaconChain/BeaconChainDepositsList.tsx @@ -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; + view: 'list' | 'block' | 'address'; +}); + +const DepositsList = ({ items, view, isLoading }: Props) => { + const { cutRef, renderedItemsNum } = useLazyRenderedList(items, !isLoading); + + return ( + + { items.slice(0, renderedItemsNum).map((item, index) => { + + const key = item.index + (isLoading ? String(index) : ''); + return ( + + ); + }) } +
+ + ); +}; + +export default React.memo(DepositsList); diff --git a/ui/deposits/beaconChain/BeaconChainDepositsListItem.tsx b/ui/deposits/beaconChain/BeaconChainDepositsListItem.tsx new file mode 100644 index 0000000000..2aa0a65503 --- /dev/null +++ b/ui/deposits/beaconChain/BeaconChainDepositsListItem.tsx @@ -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 ( + + + Transaction hash + + + + + { view !== 'block' && ( + <> + Block + + + + + Age + + + + + ) } + + Value + + + + + { view !== 'address' && ( + <> + From + + + + + ) } + + PubKey + + + + + Signature + + + + + Status + + + + + + ); +}; + +export default BeaconChainDepositsListItem; diff --git a/ui/deposits/beaconChain/BeaconChainDepositsTable.tsx b/ui/deposits/beaconChain/BeaconChainDepositsTable.tsx new file mode 100644 index 0000000000..49e25c9e6f --- /dev/null +++ b/ui/deposits/beaconChain/BeaconChainDepositsTable.tsx @@ -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; + view: 'list' | 'address' | 'block'; + }; + +const BeaconChainDepositsTable = ({ items, isLoading, top, view }: Props) => { + const { cutRef, renderedItemsNum } = useLazyRenderedList(items, !isLoading); + + if (!feature.isEnabled) { + return null; + } + + return ( + + + + Transaction hash + { view !== 'block' && Block } + { view !== 'block' && Timestamp } + { `Value ${ feature.currency.symbol }` } + { view !== 'address' && From } + PubKey + Signature + Status + + + + { items.slice(0, renderedItemsNum).map((item, index) => ( + + )) } + + + + ); +}; + +export default BeaconChainDepositsTable; diff --git a/ui/deposits/beaconChain/BeaconChainDepositsTableItem.tsx b/ui/deposits/beaconChain/BeaconChainDepositsTableItem.tsx new file mode 100644 index 0000000000..68dddd5615 --- /dev/null +++ b/ui/deposits/beaconChain/BeaconChainDepositsTableItem.tsx @@ -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 ( + + + + + { view !== 'block' && ( + + + + ) } + { view !== 'block' && ( + + + + ) } + + + + { view !== 'address' && ( + + + + ) } + + + + + + + + + + + ); +}; + +export default BeaconChainDepositsTableItem; diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index d4e85fa456..811896ad5c 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -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: , } : undefined, + config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.beacon_deposits_count ? + { + id: 'deposits', + title: 'Deposits', + count: addressTabsCountersQuery.data?.beacon_deposits_count, + component: , + } : + undefined, config.features.beaconChain.isEnabled && addressTabsCountersQuery.data?.withdrawals_count ? { id: 'withdrawals', diff --git a/ui/pages/BeaconChainDeposits.pw.tsx b/ui/pages/BeaconChainDeposits.pw.tsx new file mode 100644 index 0000000000..b9b5a3c566 --- /dev/null +++ b/ui/pages/BeaconChainDeposits.pw.tsx @@ -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(); + 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(); + await expect(component).toHaveScreenshot(); + }); +}); diff --git a/ui/pages/BeaconChainDeposits.tsx b/ui/pages/BeaconChainDeposits.tsx new file mode 100644 index 0000000000..120b91c822 --- /dev/null +++ b/ui/pages/BeaconChainDeposits.tsx @@ -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 ? ( + <> + + { data.items.map(((item, index) => ( + + ))) } + + + + + + ) : null; + + const text = (() => { + if (countersQuery.isError || !feature.isEnabled) { + return null; + } + + return ( + + { countersQuery.data && ( + + { BigNumber(countersQuery.data.deposits_count).toFormat() } deposits processed + + ) } + + ); + })(); + + const actionBar = ; + + return ( + <> + + + { content } + + + ); +}; + +export default Deposits; diff --git a/ui/pages/Block.tsx b/ui/pages/Block.tsx index 0f3a8e01cf..f1fdaf7145 100644 --- a/ui/pages/Block.tsx +++ b/ui/pages/Block.tsx @@ -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 = () => { ), } : null, + config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.beacon_deposits_count) ? + { + id: 'deposits', + title: 'Deposits', + component: ( + <> + { blockDepositsQuery.isDegradedData && } + + + ), + } : 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; } diff --git a/ui/pages/__screenshots__/BeaconChainDeposits.pw.tsx_default_base-view-1.png b/ui/pages/__screenshots__/BeaconChainDeposits.pw.tsx_default_base-view-1.png new file mode 100644 index 0000000000..16ea26a197 Binary files /dev/null and b/ui/pages/__screenshots__/BeaconChainDeposits.pw.tsx_default_base-view-1.png differ diff --git a/ui/pages/__screenshots__/BeaconChainDeposits.pw.tsx_default_mobile-base-view-1.png b/ui/pages/__screenshots__/BeaconChainDeposits.pw.tsx_default_mobile-base-view-1.png new file mode 100644 index 0000000000..aee339867a Binary files /dev/null and b/ui/pages/__screenshots__/BeaconChainDeposits.pw.tsx_default_mobile-base-view-1.png differ diff --git a/ui/shared/beacon/BeaconChainDepositSignature.tsx b/ui/shared/beacon/BeaconChainDepositSignature.tsx new file mode 100644 index 0000000000..2b1b9b0bc6 --- /dev/null +++ b/ui/shared/beacon/BeaconChainDepositSignature.tsx @@ -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 ( + + + { signature } + + + + ); +}; + +export default React.memo(chakra(BeaconChainDepositSignature)); diff --git a/ui/shared/beacon/BeaconChainDepositStatusTag.tsx b/ui/shared/beacon/BeaconChainDepositStatusTag.tsx new file mode 100644 index 0000000000..ba36961846 --- /dev/null +++ b/ui/shared/beacon/BeaconChainDepositStatusTag.tsx @@ -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 ; +}; + +export default BeaconChainDepositStatusTag; diff --git a/ui/shared/beacon/BeaconChainValidatorLink.tsx b/ui/shared/beacon/BeaconChainValidatorLink.tsx new file mode 100644 index 0000000000..d483894e4e --- /dev/null +++ b/ui/shared/beacon/BeaconChainValidatorLink.tsx @@ -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 = ( + + { pubkey } + + ); + } else { + content = ( + + + { pubkey } + + + ); + } + return ( + + { content } + + + ); +}; + +export default React.memo(BeaconChainValidatorLink);