Commit 86b7372b authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #2277 from blockscout/fe-2261

Celo: Epoch rewards tab on Address view
parents 5d6fd12d bc383c17
......@@ -37,6 +37,7 @@ import type {
AddressMudRecordsFilter,
AddressMudRecordsSorting,
AddressMudRecord,
AddressEpochRewardsResponse,
} from 'types/api/address';
import type { AddressesResponse, AddressesMetadataSearchResult, AddressesMetadataSearchFilters } from 'types/api/addresses';
import type { AddressMetadataInfo, PublicTagTypesResponse } from 'types/api/addressMetadata';
......@@ -505,6 +506,11 @@ export const RESOURCES = {
pathParams: [ 'hash' as const ],
filterFields: [],
},
address_epoch_rewards: {
path: '/api/v2/addresses/:hash/election-rewards',
pathParams: [ 'hash' as const ],
filterFields: [],
},
// CONTRACT
contract: {
......@@ -1002,7 +1008,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward
'addresses' | 'addresses_metadata_search' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' |
'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' |
'address_logs' | 'address_tokens' | 'address_nfts' | 'address_collections' | 'address_epoch_rewards' |
'token_transfers' | 'token_holders' | 'token_inventory' | 'tokens' | 'tokens_bridged' |
'token_instance_transfers' | 'token_instance_holders' |
'verified_contracts' |
......@@ -1182,6 +1188,7 @@ Q extends 'address_mud_tables' ? AddressMudTables :
Q extends 'address_mud_tables_count' ? number :
Q extends 'address_mud_records' ? AddressMudRecords :
Q extends 'address_mud_record' ? AddressMudRecord :
Q extends 'address_epoch_rewards' ? AddressEpochRewardsResponse :
Q extends 'withdrawals' ? WithdrawalsResponse :
Q extends 'withdrawals_counters' ? WithdrawalsCounters :
never;
......
import type { AddressEpochRewardsResponse } from 'types/api/address';
import { tokenInfo } from 'mocks/tokens/tokenInfo';
import { withEns, withName, withoutName } from './address';
export const epochRewards: AddressEpochRewardsResponse = {
items: [
{
type: 'delegated_payment',
amount: '136609473658452408568',
account: withName,
associated_account: withName,
block_hash: '0x',
block_number: 26369280,
epoch_number: 1526,
token: tokenInfo,
},
{
type: 'group',
amount: '117205842355246195095',
account: withoutName,
associated_account: withoutName,
block_hash: '0x',
block_number: 26352000,
epoch_number: 1525,
token: tokenInfo,
},
{
type: 'validator',
amount: '125659647325556554060',
account: withEns,
associated_account: withEns,
block_hash: '0x',
block_number: 26300160,
epoch_number: 1524,
token: tokenInfo,
},
],
next_page_params: null,
};
......@@ -3,6 +3,7 @@ import type {
AddressCoinBalanceHistoryItem,
AddressCollection,
AddressCounters,
AddressEpochRewardsItem,
AddressMudTableItem,
AddressNFT,
AddressTabsCounters,
......@@ -10,7 +11,7 @@ import type {
} from 'types/api/address';
import type { AddressesItem } from 'types/api/addresses';
import { ADDRESS_HASH } from './addressParams';
import { ADDRESS_HASH, ADDRESS_PARAMS } from './addressParams';
import { MUD_SCHEMA, MUD_TABLE } from './mud';
import { TOKEN_INFO_ERC_1155, TOKEN_INFO_ERC_20, TOKEN_INFO_ERC_721, TOKEN_INFO_ERC_404, TOKEN_INSTANCE } from './token';
import { TX_HASH } from './tx';
......@@ -116,3 +117,14 @@ export const ADDRESS_MUD_TABLE_ITEM: AddressMudTableItem = {
schema: MUD_SCHEMA,
table: MUD_TABLE,
};
export const EPOCH_REWARD_ITEM: AddressEpochRewardsItem = {
amount: '136609473658452408568',
block_number: 10355938,
type: 'voter',
token: TOKEN_INFO_ERC_20,
block_hash: '0x5956a847d8089e254e02e5111cad6992b99ceb9e5c2dc4343fd53002834c4dc6',
account: ADDRESS_PARAMS,
epoch_number: 1526,
associated_account: ADDRESS_PARAMS,
};
import type { Transaction } from 'types/api/transaction';
import type { UserTags, AddressImplementation } from './addressParams';
import type { Block } from './block';
import type { UserTags, AddressImplementation, AddressParam } from './addressParams';
import type { Block, EpochRewardsType } from './block';
import type { InternalTransaction } from './internalTransaction';
import type { MudWorldSchema, MudWorldTable } from './mudWorlds';
import type { NFTTokenType, TokenInfo, TokenInstance, TokenType } from './token';
......@@ -191,6 +191,7 @@ export type AddressTabsCounters = {
transactions_count: number | null;
validations_count: number | null;
withdrawals_count: number | null;
celo_election_rewards_count?: number | null;
}
// MUD framework
......@@ -245,3 +246,25 @@ export type AddressMudRecord = {
schema: MudWorldSchema;
table: MudWorldTable;
}
export type AddressEpochRewardsResponse = {
items: Array<AddressEpochRewardsItem>;
next_page_params: {
amount: string;
associated_account_address_hash: string;
block_number: number;
items_count: number;
type: EpochRewardsType;
} | null;
}
export type AddressEpochRewardsItem = {
type: EpochRewardsType;
token: TokenInfo;
amount: string;
block_number: number;
block_hash: string;
account: AddressParam;
epoch_number: number;
associated_account: AddressParam;
}
......@@ -144,6 +144,8 @@ export interface BlockEpochElectionReward {
total: string;
}
export type EpochRewardsType = 'group' | 'validator' | 'delegated_payment' | 'voter';
export interface BlockEpoch {
number: number;
distribution: {
......@@ -151,12 +153,7 @@ export interface BlockEpoch {
community_transfer: TokenTransfer | null;
reserve_bolster_transfer: TokenTransfer | null;
};
aggregated_election_rewards: {
delegated_payment: BlockEpochElectionReward | null;
group: BlockEpochElectionReward | null;
validator: BlockEpochElectionReward | null;
voter: BlockEpochElectionReward | null;
};
aggregated_election_rewards: Record<EpochRewardsType, BlockEpochElectionReward | null>;
}
export interface BlockEpochElectionRewardDetails {
......
import { Box } from '@chakra-ui/react';
import React from 'react';
import { epochRewards } from 'mocks/address/epochRewards';
import { test, expect } from 'playwright/lib';
import AddressEpochRewards from './AddressEpochRewards';
const ADDRESS_HASH = '0x1234';
const hooksConfig = {
router: {
query: { hash: ADDRESS_HASH },
},
};
test('base view +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse('address_epoch_rewards', epochRewards, { pathParams: { hash: ADDRESS_HASH } });
const component = await render(
<Box pt={{ base: '134px', lg: 6 }}>
<AddressEpochRewards/>
</Box>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
import { Hide, Show } 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 { EPOCH_REWARD_ITEM } from 'stubs/address';
import { generateListStub } from 'stubs/utils';
import AddressEpochRewardsTable from 'ui/address/epochRewards/AddressEpochRewardsTable';
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';
import AddressEpochRewardsListItem from './epochRewards/AddressEpochRewardsListItem';
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
shouldRender?: boolean;
isQueryEnabled?: boolean;
}
const AddressEpochRewards = ({ scrollRef, shouldRender = true, isQueryEnabled = true }: Props) => {
const router = useRouter();
const isMounted = useIsMounted();
const hash = getQueryParamString(router.query.hash);
const rewardsQuery = useQueryWithPages({
resourceName: 'address_epoch_rewards',
pathParams: {
hash,
},
scrollRef,
options: {
enabled: isQueryEnabled && Boolean(hash),
placeholderData: generateListStub<'address_epoch_rewards'>(EPOCH_REWARD_ITEM, 50, { next_page_params: {
amount: '1',
items_count: 50,
type: 'voter',
associated_account_address_hash: '1',
block_number: 10355938,
} }),
},
});
if (!isMounted || !shouldRender) {
return null;
}
const content = rewardsQuery.data?.items ? (
<>
<Hide below="lg" ssr={ false }>
<AddressEpochRewardsTable
items={ rewardsQuery.data.items }
top={ rewardsQuery.pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
isLoading={ rewardsQuery.isPlaceholderData }
/>
</Hide>
<Show below="lg" ssr={ false }>
{ rewardsQuery.data.items.map((item, index) => (
<AddressEpochRewardsListItem
key={ item.block_hash + item.type + item.account.hash + item.associated_account.hash + (rewardsQuery.isPlaceholderData ? String(index) : '') }
item={ item }
isLoading={ rewardsQuery.isPlaceholderData }
/>
)) }
</Show>
</>
) : null;
const actionBar = rewardsQuery.pagination.isVisible ? (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...rewardsQuery.pagination }/>
</ActionBar>
) : null;
return (
<DataListDisplay
isError={ rewardsQuery.isError }
items={ rewardsQuery.data?.items }
emptyText="There are no epoch rewards for this address."
content={ content }
actionBar={ actionBar }
/>
);
};
export default AddressEpochRewards;
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { AddressEpochRewardsItem } from 'types/api/address';
import getCurrencyValue from 'lib/getCurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
type Props = {
item: AddressEpochRewardsItem;
isLoading?: boolean;
};
const AddressEpochRewardsListItem = ({ item, isLoading }: Props) => {
const { valueStr } = getCurrencyValue({ value: item.amount, accuracy: 2, decimals: item.token.decimals });
return (
<ListItemMobileGrid.Container gridTemplateColumns="100px auto">
<ListItemMobileGrid.Label isLoading={ isLoading }>Block</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlockEntity
number={ Number(item.block_number) }
isLoading={ isLoading }
noIcon
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Epoch #</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
{ item.epoch_number }
</ListItemMobileGrid.Value>
{ /* <ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TimeAgoWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text_secondary"
display="inline-block"
/>
</ListItemMobileGrid.Value> */ }
<ListItemMobileGrid.Label isLoading={ isLoading }>Reward type</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<EpochRewardTypeTag type={ item.type } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Associated address</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<AddressEntity
address={ item.associated_account }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Value</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading } display="flex" alignItems="center" gap={ 2 }>
{ valueStr }
<TokenEntity token={ item.token } isLoading={ isLoading } onlySymbol width="auto" noCopy/>
</Skeleton>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default AddressEpochRewardsListItem;
import { Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { AddressEpochRewardsItem } from 'types/api/address';
import { default as Thead } from 'ui/shared/TheadSticky';
import AddressEpochRewardsTableItem from './AddressEpochRewardsTableItem';
type Props = {
items: Array<AddressEpochRewardsItem>;
isLoading?: boolean;
top: number;
};
const AddressEpochRewardsTable = ({ items, isLoading, top }: Props) => {
return (
<Table variant="simple" size="sm" minW="1000px" style={{ tableLayout: 'auto' }}>
<Thead top={ top }>
<Tr>
<Th>Block</Th>
<Th>Reward type</Th>
<Th>Associated address</Th>
<Th isNumeric>Value</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => {
return (
<AddressEpochRewardsTableItem
key={ item.block_hash + item.type + item.account.hash + item.associated_account.hash + (isLoading ? String(index) : '') }
item={ item }
isLoading={ isLoading }
/>
);
}) }
</Tbody>
</Table>
);
};
export default AddressEpochRewardsTable;
import { Flex, Td, Tr, Text, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { AddressEpochRewardsItem } from 'types/api/address';
import getCurrencyValue from 'lib/getCurrencyValue';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag';
type Props = {
item: AddressEpochRewardsItem;
isLoading?: boolean;
};
const AddressEpochRewardsTableItem = ({ item, isLoading }: Props) => {
const { valueStr } = getCurrencyValue({ value: item.amount, decimals: item.token.decimals });
return (
<Tr>
<Td verticalAlign="middle">
<Flex alignItems="center" gap={ 3 }>
<BlockEntity number={ item.block_number } isLoading={ isLoading } noIcon/>
<Text color="text_secondary" fontWeight={ 600 }>{ `Epoch # ${ item.epoch_number }` }</Text>
{ /* no timestamp from API, will be added later */ }
{ /* <TimeAgoWithTooltip timestamp={ item } isLoading={ isLoading }/> */ }
</Flex>
</Td>
<Td verticalAlign="middle">
<EpochRewardTypeTag type={ item.type } isLoading={ isLoading }/>
</Td>
<Td verticalAlign="middle">
<AddressEntity address={ item.associated_account } isLoading={ isLoading }/>
</Td>
<Td verticalAlign="middle" isNumeric>
<Skeleton isLoaded={ !isLoading } display="flex" alignItems="center" gap={ 2 } justifyContent="flex-end">
{ valueStr }
<TokenEntity token={ item.token } isLoading={ isLoading } onlySymbol width="auto" noCopy/>
</Skeleton>
</Td>
</Tr>
);
};
export default AddressEpochRewardsTableItem;
import React from 'react';
import type { BlockEpoch } from 'types/api/block';
import Tag from 'ui/shared/chakra/Tag';
interface Props {
type: keyof BlockEpoch['aggregated_election_rewards'];
isLoading?: boolean;
}
const BlockEpochElectionRewardType = ({ type, isLoading }: Props) => {
switch (type) {
case 'delegated_payment':
return <Tag colorScheme="blue" isLoading={ isLoading }>Delegated payments</Tag>;
case 'group':
return <Tag colorScheme="teal" isLoading={ isLoading }>Validator group rewards</Tag>;
case 'validator':
return <Tag colorScheme="purple" isLoading={ isLoading }>Validator rewards</Tag>;
case 'voter':
return <Tag colorScheme="yellow" isLoading={ isLoading }>Voting rewards</Tag>;
}
};
export default React.memo(BlockEpochElectionRewardType);
......@@ -5,10 +5,10 @@ import type { BlockEpoch, BlockEpochElectionReward } from 'types/api/block';
import getCurrencyValue from 'lib/getCurrencyValue';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag';
import IconSvg from 'ui/shared/IconSvg';
import BlockEpochElectionRewardDetailsMobile from './BlockEpochElectionRewardDetailsMobile';
import BlockEpochElectionRewardType from './BlockEpochElectionRewardType';
interface Props {
data: BlockEpochElectionReward;
......@@ -53,7 +53,7 @@ const BlockEpochElectionRewardsListItem = ({ data, isLoading, type }: Props) =>
/>
</Skeleton>
) : <Box boxSize={ 6 }/> }
<BlockEpochElectionRewardType type={ type } isLoading={ isLoading }/>
<EpochRewardTypeTag type={ type } isLoading={ isLoading }/>
<Skeleton isLoaded={ !isLoading }>{ data.count }</Skeleton>
<Flex columnGap={ 2 } alignItems="center" ml="auto" fontWeight={ 500 }>
<Skeleton isLoaded={ !isLoading }>{ valueStr }</Skeleton>
......
......@@ -5,10 +5,10 @@ import type { BlockEpoch, BlockEpochElectionReward } from 'types/api/block';
import getCurrencyValue from 'lib/getCurrencyValue';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import EpochRewardTypeTag from 'ui/shared/EpochRewardTypeTag';
import IconSvg from 'ui/shared/IconSvg';
import BlockEpochElectionRewardDetailsDesktop from './BlockEpochElectionRewardDetailsDesktop';
import BlockEpochElectionRewardType from './BlockEpochElectionRewardType';
import { getRewardNumText } from './utils';
interface Props {
......@@ -54,7 +54,7 @@ const BlockEpochElectionRewardsTableItem = ({ isLoading, data, type }: Props) =>
) }
</Td>
<Td borderColor={ mainRowBorderColor }>
<BlockEpochElectionRewardType type={ type } isLoading={ isLoading }/>
<EpochRewardTypeTag type={ type } isLoading={ isLoading }/>
</Td>
<Td borderColor={ mainRowBorderColor }>
<Skeleton isLoaded={ !isLoading } fontWeight={ 400 } my={ 1 }>
......
......@@ -24,6 +24,7 @@ import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance';
import AddressContract from 'ui/address/AddressContract';
import AddressDetails from 'ui/address/AddressDetails';
import AddressEpochRewards from 'ui/address/AddressEpochRewards';
import AddressInternalTxs from 'ui/address/AddressInternalTxs';
import AddressLogs from 'ui/address/AddressLogs';
import AddressMud from 'ui/address/AddressMud';
......@@ -195,6 +196,12 @@ const AddressPageContent = () => {
count: addressTabsCountersQuery.data?.internal_txs_count,
component: <AddressInternalTxs scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading } isQueryEnabled={ areQueriesEnabled }/>,
},
addressTabsCountersQuery.data?.celo_election_rewards_count ? {
id: 'epoch_rewards',
title: 'Epoch rewards',
count: addressTabsCountersQuery.data?.celo_election_rewards_count,
component: <AddressEpochRewards scrollRef={ tabsScrollRef } shouldRender={ !isTabsLoading } isQueryEnabled={ areQueriesEnabled }/>,
} : undefined,
{
id: 'coin_balance_history',
title: 'Coin balance history',
......
import { Tooltip } from '@chakra-ui/react';
import React from 'react';
import type { EpochRewardsType } from 'types/api/block';
import Tag from 'ui/shared/chakra/Tag';
type Props = {
type: EpochRewardsType;
isLoading?: boolean;
};
const TYPE_TAGS: Record<EpochRewardsType, { text: string; label: string; color: string }> = {
group: {
text: 'Validator group rewards',
// eslint-disable-next-line max-len
label: 'Reward given to a validator group. The address being viewed is the group\'s address; the associated address is the validator\'s address on whose behalf the reward was paid.',
color: 'teal',
},
validator: {
text: 'Validator rewards',
label: 'Reward given to a validator. The address being viewed is the validator\'s address; the associated address is the validator group\'s address.',
color: 'purple',
},
delegated_payment: {
text: 'Delegated payments',
// eslint-disable-next-line max-len
label: 'Reward portion delegated by a validator to another address. The address being viewed is the beneficiary receiving the reward; the associated address is the validator who set the delegation.',
color: 'blue',
},
voter: {
text: 'Voting rewards',
label: 'Reward given to a voter. The address being viewed is the voter\'s address; the associated address is the group address.',
color: 'yellow',
},
};
const EpochRewardTypeTag = ({ type, isLoading }: Props) => {
const { text, label, color } = TYPE_TAGS[type];
return (
<Tooltip label={ label } maxW="322px" textAlign="center">
<Tag colorScheme={ color } isLoading={ isLoading }>
{ text }
</Tag>
</Tooltip>
);
};
export default React.memo(EpochRewardTypeTag);
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