feat(governance): epoch total rewards tables (#2889)

This commit is contained in:
Sam Keen 2023-02-10 18:01:30 +00:00 committed by GitHub
parent 718959b920
commit fa415318ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1104 additions and 179 deletions

View File

@ -1,4 +1,4 @@
const connectToVegaBtn = '[data-testid="connect-to-vega-wallet-btn"]';
const viewToggle = '[data-testid="epoch-reward-view-toggle-total"]';
const warning = '[data-testid="callout"]';
context(
@ -15,7 +15,7 @@ context(
});
it('should have rewards header visible', function () {
cy.verify_page_header('Rewards');
cy.verify_page_header('Rewards and fees');
});
it('should have epoch warning', function () {
@ -27,10 +27,8 @@ context(
);
});
it('should have connect Vega wallet button', function () {
cy.get(connectToVegaBtn)
.should('be.visible')
.and('have.text', 'Connect Vega wallet');
it('should have toggle for seeing total vs individual rewards', function () {
cy.get(viewToggle).should('be.visible');
});
});
}

View File

@ -32,7 +32,9 @@ export function TemplateSidebar({ children, sidebar }: TemplateSidebarProps) {
<ViewingAsBanner pubKey={pubKey} disconnect={disconnect} />
) : null}
<div className="w-full border-b border-neutral-700 lg:grid lg:grid-rows-[1fr] lg:grid-cols-[1fr_450px]">
<main className="col-start-1 p-4">{children}</main>
<main className="max-w-[100vw] col-start-1 p-4 overflow-auto">
{children}
</main>
<aside className="col-start-2 row-start-1 row-span-2 hidden lg:block p-4 bg-banner bg-contain border-l border-neutral-700">
{sidebar.map((Component, i) => (
<section className="mb-4 last:mb-0" key={i}>

View File

@ -1,7 +1,7 @@
import type { ObservableQuery } from '@apollo/client';
import { useEffect } from 'react';
export const useRefreshValidators = (
export const useRefreshAfterEpoch = (
epochExpiry: string | undefined,
refetch: ObservableQuery['refetch']
) => {

View File

@ -14,7 +14,7 @@
"pageTitleProposals": "Proposals",
"pageTitleDepositLp": "Deposit liquidity token for $VEGA rewards",
"pageTitleWithdrawLp": "Withdraw SLP and Rewards",
"pageTitleRewards": "Rewards",
"pageTitleRewards": "Rewards and fees",
"pageTitleRejectedProposals": "Rejected proposals",
"pageTitleValidators": "Validators",
"Vesting": "Vesting",
@ -197,10 +197,6 @@
"STATE_PASSED": "Passed",
"STATE_OPEN": "Open",
"STATE_WAITING_FOR_NODE_VOTE": "Waiting for node vote",
"NewMarket": "New market",
"UpdateMarket": "Update market",
"NewAsset": "New asset",
"UpdateAsset": "Update asset",
"UpdateNetworkParameter": "Network parameter",
"NewFreeform": "Freeform",
"tokenVotes": "Token votes",
@ -436,12 +432,32 @@
"associatedWithVegaKeys": "Associated with Vega keys",
"thisEpoch": "This Epoch",
"nextEpoch": "Next epoch",
"rewardsPara1": "Rewards are paid out from the treasury at the end of an epoch.",
"rewardsPara2": "This page lists all the rewards that your Vega key has received.",
"rewardsPara3": "This delay is set by a network parameter",
"rewardsIntro": "Earn rewards and infrastructure fees for trading and maintaining the network.",
"rewardsCallout": "Rewards are credited {{duration}} after the epoch ends.",
"rewardsCalloutDetail": "This delay is set by a network parameter",
"noRewards": "The Vega key has not been credited any rewards since the previous network checkpoint.",
"seeHowRewardsAreCalculated": "See how rewards are calculated",
"rewardType": "Reward type",
"rewardsAndFeesReceived": "Rewards and fees received",
"ThisDoesNotIncludeFeesReceivedForMakersOrLiquidityProviders": "This does not include fees received for makers or liquidity providers",
"totalDistributed": "TOTAL DISTRIBUTED",
"earnedByMe": "EARNED BY ME",
"noRewardsHaveBeenDistributedYet": "NO REWARDS HAVE BEEN DISTRIBUTED YET",
"rewardsColAssetHeader": "ASSET",
"rewardsColStakingHeader": "STAKING",
"rewardsColStakingTooltip": "Staking rewards supplement infrastructure fees in the early stages of the network, rewarding validators and those who stake them for maintaining the network",
"rewardsColInfraHeader": "INFRA FEES",
"rewardsColInfraTooltip": "Infrastructure fees are incurred across the network during trading. They are distributed to validators according to their share of total stake on the network, and passed onto those who stake them, proportionate to their own stake after validator commission is taken",
"rewardsColPriceTakingHeader": "PRICE TAKING",
"rewardsColPriceTakingTooltip": "Price taking rewards are based on the proportion of the total maker fees you paid while trading, on markets where there is a funded reward",
"rewardsColPriceMakingHeader": "PRICE MAKING",
"rewardsColPriceMakingTooltip": "Price making rewards are based on the proportion of the total maker fees you received while trading, on markets where there is a funded reward",
"rewardsColLiquidityProvisionHeader": "LIQUIDITY PROVISION",
"rewardsColLiquidityProvisionTooltip": "Liquidity provision rewards are distributed based on how much you have earned in liquidity fees, funded by a liquidity reward pool for that market",
"rewardsColMarketCreationHeader": "MARKET CREATION",
"rewardsColMarketCreationTooltip": "Market creation rewards are paid out to the creator of any market that exceeds a set threshold of cumulative volume in a given epoch, currently [rewards.marketCreationQuantumMultiple]",
"rewardsColTotalHeader": "TOTAL",
"checkBackSoon": "Check back soon",
"yourStake": "Your stake",
"reward": "Reward",
"shareOfReward": "Share of reward",

View File

@ -8,7 +8,7 @@ import {
RoundedWrapper,
} from '@vegaprotocol/ui-toolkit';
import { useDocumentTitle } from '../../hooks/use-document-title';
import { useRefreshValidators } from '../../hooks/use-refresh-validators';
import { useRefreshAfterEpoch } from '../../hooks/use-refresh-after-epoch';
import { ProposalsListItem } from '../proposals/components/proposals-list-item';
import Routes from '../routes';
import {
@ -156,7 +156,7 @@ const GovernanceHome = ({ name }: RouteChildProps) => {
refetch,
} = useNodesQuery();
useRefreshValidators(validatorsData?.epoch.timestamps.expiry, refetch);
useRefreshAfterEpoch(validatorsData?.epoch.timestamps.expiry, refetch);
const proposals = useMemo(
() =>

View File

@ -0,0 +1,69 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { removePaginationWrapper } from '@vegaprotocol/react-helpers';
import { useRewardsQuery } from '../home/__generated__/Rewards';
import { ENV } from '../../../config';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { RewardTable } from './reward-table';
export const RewardInfo = () => {
const { t } = useTranslation();
const { pubKey } = useVegaWallet();
const { delegationsPagination } = ENV;
const { data, loading, error } = useRewardsQuery({
variables: {
partyId: pubKey || '',
delegationsPagination: delegationsPagination
? {
first: Number(delegationsPagination),
}
: undefined,
},
skip: !pubKey,
});
const rewards = useMemo(() => {
if (!data?.party || !data.party.rewardsConnection?.edges?.length) return [];
return removePaginationWrapper(data.party.rewardsConnection.edges);
}, [data]);
const delegations = useMemo(() => {
if (!data?.party || !data.party.delegationsConnection?.edges?.length) {
return [];
}
return removePaginationWrapper(data.party.delegationsConnection.edges);
}, [data]);
return (
<AsyncRenderer
loading={loading}
error={error}
data={data}
render={() => (
<div>
<p>
{t('Connected Vega key')}: {pubKey}
</p>
{rewards.length ? (
rewards.map((reward, i) => {
if (!reward) return null;
return (
<RewardTable
key={i}
reward={reward}
delegations={delegations || []}
/>
);
})
) : (
<p>{t('noRewards')}</p>
)}
</div>
)}
/>
);
};

View File

@ -1,66 +1,15 @@
import { format } from 'date-fns';
import React from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyValueTable, KeyValueTableRow } from '@vegaprotocol/ui-toolkit';
import { useAppState } from '../../../contexts/app-state/app-state-context';
import { formatNumber, toBigNum } from '@vegaprotocol/react-helpers';
import { BigNumber } from '../../../lib/bignumber';
import { KeyValueTable, KeyValueTableRow } from '@vegaprotocol/ui-toolkit';
import { format } from 'date-fns';
import { DATE_FORMAT_DETAILED } from '../../../lib/date-formats';
import type {
RewardsQuery,
RewardFieldsFragment,
DelegationFieldsFragment,
} from './__generated__/Rewards';
import {
formatNumber,
removePaginationWrapper,
toBigNum,
} from '@vegaprotocol/react-helpers';
import { useAppState } from '../../../contexts/app-state/app-state-context';
interface RewardInfoProps {
data: RewardsQuery | undefined;
currVegaKey: string;
}
export const RewardInfo = ({ data, currVegaKey }: RewardInfoProps) => {
const { t } = useTranslation();
const rewards = React.useMemo(() => {
if (!data?.party || !data.party.rewardsConnection?.edges?.length) return [];
return removePaginationWrapper(data.party.rewardsConnection.edges);
}, [data]);
const delegations = React.useMemo(() => {
if (!data?.party || !data.party.delegationsConnection?.edges?.length) {
return [];
}
return removePaginationWrapper(data.party.delegationsConnection.edges);
}, [data]);
return (
<div>
<p>
{t('Connected Vega key')}: {currVegaKey}
</p>
{rewards.length ? (
rewards.map((reward, i) => {
if (!reward) return null;
return (
<RewardTable
key={i}
reward={reward}
delegations={delegations || []}
/>
);
})
) : (
<p>{t('noRewards')}</p>
)}
</div>
);
};
RewardFieldsFragment,
} from '../home/__generated__/Rewards';
interface RewardTableProps {
reward: RewardFieldsFragment;
@ -74,7 +23,7 @@ export const RewardTable = ({ reward, delegations }: RewardTableProps) => {
} = useAppState();
// Get your stake for epoch in which you have rewards
const stakeForEpoch = React.useMemo(() => {
const stakeForEpoch = useMemo(() => {
if (!delegations.length) return '0';
const delegationsForEpoch = delegations

View File

@ -0,0 +1,43 @@
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { useEpochAssetsRewardsQuery } from '../home/__generated__/Rewards';
import { useRefreshAfterEpoch } from '../../../hooks/use-refresh-after-epoch';
import { generateEpochTotalRewardsList } from './generate-epoch-total-rewards-list';
import { NoRewards } from '../no-rewards';
import { EpochTotalRewardsTable } from './epoch-total-rewards-table';
export const EpochRewards = () => {
const { data, loading, error, refetch } = useEpochAssetsRewardsQuery({
variables: {
epochRewardSummariesPagination: {
first: 10,
},
},
});
useRefreshAfterEpoch(data?.epoch.timestamps.expiry, refetch);
const epochRewardSummaries = generateEpochTotalRewardsList(data) || [];
return (
<AsyncRenderer
loading={loading}
error={error}
data={data}
render={() => (
<div
className="max-w-full overflow-auto"
data-testid="epoch-rewards-total"
>
{epochRewardSummaries.length === 0 ? (
<NoRewards />
) : (
<>
{epochRewardSummaries.map((aggregatedEpochSummary) => (
<EpochTotalRewardsTable data={aggregatedEpochSummary} />
))}
</>
)}
</div>
)}
/>
);
};

View File

@ -0,0 +1,36 @@
import { render } from '@testing-library/react';
import { EpochTotalRewardsTable } from './epoch-total-rewards-table';
import { AccountType } from '@vegaprotocol/types';
const mockData = {
epoch: 4431,
assetRewards: [
{
assetId:
'b340c130096819428a62e5df407fd6abe66e444b89ad64f670beb98621c9c663',
name: 'tDAI TEST',
rewards: [
{
rewardType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
amount: '295',
},
],
totalAmount: '295',
},
],
};
describe('EpochTotalRewardsTable', () => {
it('should render correctly', () => {
const { getByTestId } = render(<EpochTotalRewardsTable data={mockData} />);
expect(getByTestId('epoch-total-rewards-table')).toBeInTheDocument();
expect(getByTestId('asset')).toBeInTheDocument();
expect(getByTestId('global')).toBeInTheDocument();
expect(getByTestId('infra')).toBeInTheDocument();
expect(getByTestId('taker')).toBeInTheDocument();
expect(getByTestId('maker')).toBeInTheDocument();
expect(getByTestId('liquidity')).toBeInTheDocument();
expect(getByTestId('market-maker')).toBeInTheDocument();
expect(getByTestId('total')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,212 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { formatNumber } from '@vegaprotocol/react-helpers';
import { Tooltip, Icon } from '@vegaprotocol/ui-toolkit';
import { AccountType } from '@vegaprotocol/types';
import { SubHeading } from '../../../components/heading';
import type { AggregatedEpochSummary } from './generate-epoch-total-rewards-list';
interface EpochTotalRewardsGridProps {
data: AggregatedEpochSummary;
}
interface ColumnHeaderProps {
title: string;
tooltipContent?: string;
className?: string;
}
interface RewardItemProps {
value: string;
dataTestId: string;
last?: boolean;
}
const displayReward = (reward: string) => {
if (Number(reward) === 0) {
return <span className="text-vega-dark-300">0</span>;
}
if (reward.split('.')[1] && reward.split('.')[1].length > 4) {
return (
<Tooltip description={formatNumber(reward)}>
<button>{formatNumber(Number(reward).toFixed(4))}</button>
</Tooltip>
);
}
return <span>{formatNumber(reward)}</span>;
};
const gridStyles = classNames(
'grid grid-cols-[repeat(8,minmax(100px,auto))] max-w-full overflow-auto',
`border-t border-vega-dark-200`,
'text-sm'
);
const headerGridItemStyles = (last = false) =>
classNames('border-r border-b border-b-vega-dark-200', 'py-3 px-5', {
'border-r-vega-dark-150': !last,
'border-r-0': last,
});
const rowGridItemStyles = (last = false) =>
classNames('relative', 'border-r border-b border-b-vega-dark-150', {
'border-r-vega-dark-150': !last,
'border-r-0': last,
});
const ColumnHeader = ({
title,
tooltipContent,
className,
}: ColumnHeaderProps) => (
<div className={className}>
<h2 className="mb-1 text-sm text-vega-dark-300">{title}</h2>
{tooltipContent && (
<Tooltip description={tooltipContent}>
<button>
<Icon name={'info-sign'} className="text-vega-dark-200" />
</button>
</Tooltip>
)}
</div>
);
const RewardItem = ({ value, dataTestId, last }: RewardItemProps) => (
<div data-testid={dataTestId} className={rowGridItemStyles(last)}>
<div className="h-full w-5 absolute right-0 top-0 bg-gradient-to-r from-transparent to-black pointer-events-none" />
<div className="overflow-auto p-5">{displayReward(value)}</div>
<div className="h-full w-5 absolute left-0 top-0 bg-gradient-to-l from-transparent to-black pointer-events-none" />
</div>
);
export const EpochTotalRewardsTable = ({
data,
}: EpochTotalRewardsGridProps) => {
const { t } = useTranslation();
const rowData = data.assetRewards.map(({ name, rewards, totalAmount }) => ({
name,
ACCOUNT_TYPE_GLOBAL_REWARD:
rewards
.filter((r) => r.rewardType === AccountType.ACCOUNT_TYPE_GLOBAL_REWARD)
.map((r) => r.amount)[0] || '0',
ACCOUNT_TYPE_FEES_INFRASTRUCTURE:
rewards
.filter(
(r) => r.rewardType === AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE
)
.map((r) => r.amount)[0] || '0',
ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES:
rewards
.filter(
(r) =>
r.rewardType === AccountType.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES
)
.map((r) => r.amount)[0] || '0',
ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES:
rewards
.filter(
(r) =>
r.rewardType === AccountType.ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES
)
.map((r) => r.amount)[0] || '0',
ACCOUNT_TYPE_FEES_LIQUIDITY:
rewards
.filter((r) => r.rewardType === AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY)
.map((r) => r.amount)[0] || '0',
ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS:
rewards
.filter(
(r) =>
r.rewardType === AccountType.ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS
)
.map((r) => r.amount)[0] || '0',
totalAmount: totalAmount,
}));
return (
<div data-testid="epoch-total-rewards-table" className="mb-12">
<SubHeading title={`EPOCH ${data.epoch}`} />
<div className={gridStyles}>
<ColumnHeader
title={t('rewardsColAssetHeader')}
className={headerGridItemStyles()}
/>
<ColumnHeader
title={t('rewardsColStakingHeader')}
tooltipContent={t('rewardsColStakingTooltip')}
className={headerGridItemStyles()}
/>
<ColumnHeader
title={t('rewardsColInfraHeader')}
tooltipContent={t('rewardsColInfraTooltip')}
className={headerGridItemStyles()}
/>
<ColumnHeader
title={t('rewardsColPriceTakingHeader')}
tooltipContent={t('rewardsColPriceTakingTooltip')}
className={headerGridItemStyles()}
/>
<ColumnHeader
title={t('rewardsColPriceMakingHeader')}
tooltipContent={t('rewardsColPriceMakingTooltip')}
className={headerGridItemStyles()}
/>
<ColumnHeader
title={t('rewardsColLiquidityProvisionHeader')}
tooltipContent={t('rewardsColLiquidityProvisionTooltip')}
className={headerGridItemStyles()}
/>
<ColumnHeader
title={t('rewardsColMarketCreationHeader')}
tooltipContent={t('rewardsColMarketCreationTooltip')}
className={headerGridItemStyles()}
/>
<ColumnHeader
title={t('rewardsColTotalHeader')}
className={headerGridItemStyles(true)}
/>
{rowData.map((row, i) => (
<div className="contents" key={i}>
<div data-testid="asset" className={`${rowGridItemStyles()} p-5`}>
{row.name}
</div>
<RewardItem
dataTestId="global"
value={row.ACCOUNT_TYPE_GLOBAL_REWARD}
/>
<RewardItem
dataTestId="infra"
value={row.ACCOUNT_TYPE_FEES_INFRASTRUCTURE}
/>
<RewardItem
dataTestId="taker"
value={row.ACCOUNT_TYPE_REWARD_MAKER_PAID_FEES}
/>
<RewardItem
dataTestId="maker"
value={row.ACCOUNT_TYPE_REWARD_MAKER_RECEIVED_FEES}
/>
<RewardItem
dataTestId="liquidity"
value={row.ACCOUNT_TYPE_FEES_LIQUIDITY}
/>
<RewardItem
dataTestId="market-maker"
value={row.ACCOUNT_TYPE_REWARD_MARKET_PROPOSERS}
/>
<RewardItem
dataTestId="total"
value={row.totalAmount}
last={true}
/>
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,259 @@
import { generateEpochTotalRewardsList } from './generate-epoch-total-rewards-list';
import { AccountType } from '@vegaprotocol/types';
describe('generateEpochAssetRewardsList', () => {
it('should return an empty array if data is undefined', () => {
const result = generateEpochTotalRewardsList(undefined);
expect(result).toEqual([]);
});
it('should return an empty array if empty data is provided', () => {
const epochData = {
assetsConnection: {
edges: [],
},
epochRewardSummaries: {
edges: [],
},
epoch: {
timestamps: {
expiry: null,
},
},
};
const result = generateEpochTotalRewardsList(epochData);
expect(result).toEqual([]);
});
it('should return an empty array if no epochRewardSummaries are provided', () => {
const epochData = {
assetsConnection: {
edges: [
{
node: {
id: '1',
name: 'Asset 1',
},
},
{
node: {
id: '2',
name: 'Asset 2',
},
},
],
},
epochRewardSummaries: {
edges: [],
},
epoch: {
timestamps: {
expiry: null,
},
},
};
const result = generateEpochTotalRewardsList(epochData);
expect(result).toEqual([]);
});
it('should return an array of unnamed assets if no assets are provided (should not happen)', () => {
const epochData = {
assetsConnection: {
edges: [],
},
epochRewardSummaries: {
edges: [
{
node: {
epoch: 1,
assetId: '1',
rewardType: AccountType.ACCOUNT_TYPE_INSURANCE,
amount: '123',
},
},
{
node: {
epoch: 2,
assetId: '1',
rewardType: AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY,
amount: '5',
},
},
],
},
epoch: {
timestamps: {
expiry: null,
},
},
};
const result = generateEpochTotalRewardsList(epochData);
expect(result).toEqual([
{
epoch: 1,
assetRewards: [
{
assetId: '1',
name: '',
rewards: [
{
rewardType: AccountType.ACCOUNT_TYPE_INSURANCE,
amount: '123',
},
],
totalAmount: '123',
},
],
},
{
epoch: 2,
assetRewards: [
{
assetId: '1',
name: '',
rewards: [
{
rewardType: AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY,
amount: '5',
},
],
totalAmount: '5',
},
],
},
]);
});
it('should return an array of aggregated epoch summaries', () => {
const epochData = {
assetsConnection: {
edges: [
{
node: {
id: '1',
name: 'Asset 1',
},
},
{
node: {
id: '2',
name: 'Asset 2',
},
},
],
},
epochRewardSummaries: {
edges: [
{
node: {
epoch: 1,
assetId: '1',
rewardType: AccountType.ACCOUNT_TYPE_INSURANCE,
amount: '123',
},
},
{
node: {
epoch: 1,
assetId: '1',
rewardType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
amount: '100',
},
},
{
node: {
epoch: 1,
assetId: '2',
rewardType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
amount: '17.9873',
},
},
{
node: {
epoch: 1,
assetId: '2',
rewardType: AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY,
amount: '1',
},
},
{
node: {
epoch: 2,
assetId: '1',
rewardType: AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY,
amount: '5',
},
},
],
},
epoch: {
timestamps: {
expiry: null,
},
},
};
const result = generateEpochTotalRewardsList(epochData);
expect(result).toEqual([
{
epoch: 1,
assetRewards: [
{
assetId: '1',
name: 'Asset 1',
rewards: [
{
rewardType: AccountType.ACCOUNT_TYPE_INSURANCE,
amount: '123',
},
{
rewardType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
amount: '100',
},
],
totalAmount: '223',
},
{
assetId: '2',
name: 'Asset 2',
rewards: [
{
rewardType: AccountType.ACCOUNT_TYPE_FEES_INFRASTRUCTURE,
amount: '17.9873',
},
{
rewardType: AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY,
amount: '1',
},
],
totalAmount: '18.9873',
},
],
},
{
epoch: 2,
assetRewards: [
{
assetId: '1',
name: 'Asset 1',
rewards: [
{
rewardType: AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY,
amount: '5',
},
],
totalAmount: '5',
},
],
},
]);
});
});

View File

@ -0,0 +1,103 @@
import type {
EpochAssetsRewardsQuery,
EpochRewardSummaryFieldsFragment,
} from '../home/__generated__/Rewards';
import { removePaginationWrapper } from '@vegaprotocol/react-helpers';
interface EpochSummaryWithNamedReward extends EpochRewardSummaryFieldsFragment {
name: string;
}
export interface AggregatedEpochRewardSummary {
assetId: EpochRewardSummaryFieldsFragment['assetId'];
name: EpochSummaryWithNamedReward['name'];
rewards: {
rewardType: EpochRewardSummaryFieldsFragment['rewardType'];
amount: EpochRewardSummaryFieldsFragment['amount'];
}[];
totalAmount: string;
}
export interface AggregatedEpochSummary {
epoch: EpochRewardSummaryFieldsFragment['epoch'];
assetRewards: AggregatedEpochRewardSummary[];
}
export const generateEpochTotalRewardsList = (
epochData: EpochAssetsRewardsQuery | undefined
) => {
const epochRewardSummaries = removePaginationWrapper(
epochData?.epochRewardSummaries?.edges
);
const assets = removePaginationWrapper(epochData?.assetsConnection?.edges);
// Because the epochRewardSummaries don't have the asset name, we need to find it in the assets list
const epochSummariesWithNamedReward: EpochSummaryWithNamedReward[] =
epochRewardSummaries.map((epochReward) => ({
...epochReward,
name:
assets.find((asset) => asset.id === epochReward.assetId)?.name || '',
}));
// Aggregating the epoch summaries by epoch number
const aggregatedEpochSummariesByEpochNumber =
epochSummariesWithNamedReward.reduce((acc, epochReward) => {
const epoch = epochReward.epoch;
const epochSummaryIndex = acc.findIndex(
(epochSummary) => epochSummary[0].epoch === epoch
);
if (epochSummaryIndex === -1) {
acc.push([epochReward]);
} else {
acc[epochSummaryIndex].push(epochReward);
}
return acc;
}, [] as EpochSummaryWithNamedReward[][]);
// Now aggregate the array of arrays of epoch summaries by asset rewards.
const aggregatedEpochSummaries: AggregatedEpochSummary[] =
aggregatedEpochSummariesByEpochNumber.map((epochSummaries) => {
const assetRewards = epochSummaries.reduce((acc, epochSummary) => {
const assetRewardIndex = acc.findIndex(
(assetReward) =>
assetReward.assetId === epochSummary.assetId &&
assetReward.name === epochSummary.name
);
if (assetRewardIndex === -1) {
acc.push({
assetId: epochSummary.assetId,
name: epochSummary.name,
rewards: [
{
rewardType: epochSummary.rewardType,
amount: epochSummary.amount,
},
],
totalAmount: epochSummary.amount,
});
} else {
acc[assetRewardIndex].rewards.push({
rewardType: epochSummary.rewardType,
amount: epochSummary.amount,
});
acc[assetRewardIndex].totalAmount = (
Number(acc[assetRewardIndex].totalAmount) +
Number(epochSummary.amount)
).toString();
}
return acc;
}, [] as AggregatedEpochRewardSummary[]);
return {
epoch: epochSummaries[0].epoch,
assetRewards,
};
});
return aggregatedEpochSummaries;
};

View File

@ -47,3 +47,48 @@ query Rewards($partyId: ID!, $delegationsPagination: Pagination) {
}
}
}
fragment EpochRewardSummaryFields on EpochRewardSummary {
epoch
assetId
amount
rewardType
}
query EpochAssetsRewards($epochRewardSummariesPagination: Pagination) {
assetsConnection {
edges {
node {
id
name
}
}
}
epochRewardSummaries(pagination: $epochRewardSummariesPagination) {
edges {
node {
...EpochRewardSummaryFields
}
}
}
epoch {
timestamps {
expiry
}
}
}
fragment EpochFields on Epoch {
id
timestamps {
start
end
expiry
}
}
query Epoch {
epoch {
...EpochFields
}
}

View File

@ -15,6 +15,22 @@ export type RewardsQueryVariables = Types.Exact<{
export type RewardsQuery = { __typename?: 'Query', party?: { __typename?: 'Party', id: string, rewardsConnection?: { __typename?: 'RewardsConnection', edges?: Array<{ __typename?: 'RewardEdge', node: { __typename?: 'Reward', rewardType: Types.AccountType, amount: string, percentageOfTotal: string, receivedAt: any, asset: { __typename?: 'Asset', id: string, symbol: string }, party: { __typename?: 'Party', id: string }, epoch: { __typename?: 'Epoch', id: string } } } | null> | null } | null, delegationsConnection?: { __typename?: 'DelegationsConnection', edges?: Array<{ __typename?: 'DelegationEdge', node: { __typename?: 'Delegation', amount: string, epoch: number } } | null> | null } | null } | null, epoch: { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: any | null, end?: any | null, expiry?: any | null } } };
export type EpochRewardSummaryFieldsFragment = { __typename?: 'EpochRewardSummary', epoch: number, assetId: string, amount: string, rewardType: Types.AccountType };
export type EpochAssetsRewardsQueryVariables = Types.Exact<{
epochRewardSummariesPagination?: Types.InputMaybe<Types.Pagination>;
}>;
export type EpochAssetsRewardsQuery = { __typename?: 'Query', assetsConnection?: { __typename?: 'AssetsConnection', edges?: Array<{ __typename?: 'AssetEdge', node: { __typename?: 'Asset', id: string, name: string } } | null> | null } | null, epochRewardSummaries?: { __typename?: 'EpochRewardSummaryConnection', edges?: Array<{ __typename?: 'EpochRewardSummaryEdge', node: { __typename?: 'EpochRewardSummary', epoch: number, assetId: string, amount: string, rewardType: Types.AccountType } } | null> | null } | null, epoch: { __typename?: 'Epoch', timestamps: { __typename?: 'EpochTimestamps', expiry?: any | null } } };
export type EpochFieldsFragment = { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: any | null, end?: any | null, expiry?: any | null } };
export type EpochQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type EpochQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string, timestamps: { __typename?: 'EpochTimestamps', start?: any | null, end?: any | null, expiry?: any | null } } };
export const RewardFieldsFragmentDoc = gql`
fragment RewardFields on Reward {
rewardType
@ -39,6 +55,24 @@ export const DelegationFieldsFragmentDoc = gql`
epoch
}
`;
export const EpochRewardSummaryFieldsFragmentDoc = gql`
fragment EpochRewardSummaryFields on EpochRewardSummary {
epoch
assetId
amount
rewardType
}
`;
export const EpochFieldsFragmentDoc = gql`
fragment EpochFields on Epoch {
id
timestamps {
start
end
expiry
}
}
`;
export const RewardsDocument = gql`
query Rewards($partyId: ID!, $delegationsPagination: Pagination) {
party(id: $partyId) {
@ -97,4 +131,90 @@ export function useRewardsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<Re
}
export type RewardsQueryHookResult = ReturnType<typeof useRewardsQuery>;
export type RewardsLazyQueryHookResult = ReturnType<typeof useRewardsLazyQuery>;
export type RewardsQueryResult = Apollo.QueryResult<RewardsQuery, RewardsQueryVariables>;
export type RewardsQueryResult = Apollo.QueryResult<RewardsQuery, RewardsQueryVariables>;
export const EpochAssetsRewardsDocument = gql`
query EpochAssetsRewards($epochRewardSummariesPagination: Pagination) {
assetsConnection {
edges {
node {
id
name
}
}
}
epochRewardSummaries(pagination: $epochRewardSummariesPagination) {
edges {
node {
...EpochRewardSummaryFields
}
}
}
epoch {
timestamps {
expiry
}
}
}
${EpochRewardSummaryFieldsFragmentDoc}`;
/**
* __useEpochAssetsRewardsQuery__
*
* To run a query within a React component, call `useEpochAssetsRewardsQuery` and pass it any options that fit your needs.
* When your component renders, `useEpochAssetsRewardsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useEpochAssetsRewardsQuery({
* variables: {
* epochRewardSummariesPagination: // value for 'epochRewardSummariesPagination'
* },
* });
*/
export function useEpochAssetsRewardsQuery(baseOptions?: Apollo.QueryHookOptions<EpochAssetsRewardsQuery, EpochAssetsRewardsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<EpochAssetsRewardsQuery, EpochAssetsRewardsQueryVariables>(EpochAssetsRewardsDocument, options);
}
export function useEpochAssetsRewardsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<EpochAssetsRewardsQuery, EpochAssetsRewardsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<EpochAssetsRewardsQuery, EpochAssetsRewardsQueryVariables>(EpochAssetsRewardsDocument, options);
}
export type EpochAssetsRewardsQueryHookResult = ReturnType<typeof useEpochAssetsRewardsQuery>;
export type EpochAssetsRewardsLazyQueryHookResult = ReturnType<typeof useEpochAssetsRewardsLazyQuery>;
export type EpochAssetsRewardsQueryResult = Apollo.QueryResult<EpochAssetsRewardsQuery, EpochAssetsRewardsQueryVariables>;
export const EpochDocument = gql`
query Epoch {
epoch {
...EpochFields
}
}
${EpochFieldsFragmentDoc}`;
/**
* __useEpochQuery__
*
* To run a query within a React component, call `useEpochQuery` and pass it any options that fit your needs.
* When your component renders, `useEpochQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useEpochQuery({
* variables: {
* },
* });
*/
export function useEpochQuery(baseOptions?: Apollo.QueryHookOptions<EpochQuery, EpochQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<EpochQuery, EpochQueryVariables>(EpochDocument, options);
}
export function useEpochLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<EpochQuery, EpochQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<EpochQuery, EpochQueryVariables>(EpochDocument, options);
}
export type EpochQueryHookResult = ReturnType<typeof useEpochQuery>;
export type EpochLazyQueryHookResult = ReturnType<typeof useEpochLazyQuery>;
export type EpochQueryResult = Apollo.QueryResult<EpochQuery, EpochQueryVariables>;

View File

@ -1,47 +1,64 @@
import { Button, Callout, Intent, Splash } from '@vegaprotocol/ui-toolkit';
import { formatDistance } from 'date-fns';
// @ts-ignore No types available for duration-js
import Duration from 'duration-js';
import React from 'react';
import React, { useMemo, useState } from 'react';
import { formatDistance } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { ENV } from '../../../config';
import { EpochCountdown } from '../../../components/epoch-countdown';
import { Heading } from '../../../components/heading';
import { SplashLoader } from '../../../components/splash-loader';
import {
Button,
Callout,
Intent,
AsyncRenderer,
Toggle,
ExternalLink,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import {
useNetworkParams,
NetworkParams,
createDocsLinks,
} from '@vegaprotocol/react-helpers';
import {
AppStateActionType,
useAppState,
} from '../../../contexts/app-state/app-state-context';
import { RewardInfo } from './reward-info';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { useNetworkParams, NetworkParams } from '@vegaprotocol/react-helpers';
import { useRewardsQuery } from './__generated__/Rewards';
import { useEpochQuery } from './__generated__/Rewards';
import { EpochCountdown } from '../../../components/epoch-countdown';
import { Heading, SubHeading } from '../../../components/heading';
import { RewardInfo } from '../epoch-individual-awards/reward-info';
import { EpochRewards } from '../epoch-total-rewards/epoch-rewards';
import { useRefreshAfterEpoch } from '../../../hooks/use-refresh-after-epoch';
import { useEnvironment } from '@vegaprotocol/environment';
type RewardsView = 'total' | 'individual';
export const RewardsPage = () => {
const { t } = useTranslation();
const { VEGA_DOCS_URL } = useEnvironment();
const { pubKey, pubKeys } = useVegaWallet();
const [toggleRewardsView, setToggleRewardsView] =
useState<RewardsView>('total');
const { openVegaWalletDialog } = useVegaWalletDialogStore((store) => ({
openVegaWalletDialog: store.openVegaWalletDialog,
}));
const { appDispatch } = useAppState();
const { delegationsPagination } = ENV;
const { data, loading, error } = useRewardsQuery({
variables: {
partyId: pubKey || '',
delegationsPagination: delegationsPagination
? {
first: Number(delegationsPagination),
}
: undefined,
},
skip: !pubKey,
});
const { params } = useNetworkParams([
NetworkParams.reward_staking_delegation_payoutDelay,
]);
const payoutDuration = React.useMemo(() => {
const {
params,
loading: paramsLoading,
error: paramsError,
} = useNetworkParams([NetworkParams.reward_staking_delegation_payoutDelay]);
const {
data: epochData,
loading: epochLoading,
error: epochError,
refetch,
} = useEpochQuery();
useRefreshAfterEpoch(epochData?.epoch.timestamps.expiry, refetch);
const payoutDuration = useMemo(() => {
if (!params) {
return 0;
}
@ -50,76 +67,112 @@ export const RewardsPage = () => {
).milliseconds();
}, [params]);
if (error) {
return (
<section>
<p>{t('Something went wrong')}</p>
{error && <pre>{error.message}</pre>}
</section>
);
}
if (loading || !params) {
return (
<Splash>
<SplashLoader />
</Splash>
);
}
return (
<section className="rewards">
<Heading title={t('pageTitleRewards')} />
<p>{t('rewardsPara1')}</p>
<p>{t('rewardsPara2')}</p>
{payoutDuration ? (
<div className="my-8">
<Callout
title={t('rewardsCallout', {
duration: formatDistance(new Date(0), payoutDuration),
})}
headingLevel={3}
intent={Intent.Warning}
>
<p className="mb-0">{t('rewardsPara3')}</p>
</Callout>
</div>
) : null}
{!loading &&
data &&
!error &&
data.epoch.timestamps.start &&
data.epoch.timestamps.expiry && (
<section className="mb-8">
<EpochCountdown
// eslint-disable-next-line
id={data!.epoch.id}
startDate={new Date(data.epoch.timestamps.start)}
// eslint-disable-next-line
endDate={new Date(data.epoch.timestamps.expiry!)}
/>
<AsyncRenderer
loading={paramsLoading || epochLoading}
error={paramsError || epochError}
data={epochData}
render={() => (
<section className="rewards">
<Heading title={t('pageTitleRewards')} />
<p className="mb-12">
{t('rewardsIntro')}{' '}
{VEGA_DOCS_URL && (
<ExternalLink
href={createDocsLinks(VEGA_DOCS_URL).REWARDS_GUIDE}
target="_blank"
data-testid="rewards-guide-link"
className="text-white"
>
{t('seeHowRewardsAreCalculated')}
</ExternalLink>
)}
</p>
{payoutDuration ? (
<div className="my-8">
<Callout
title={t('rewardsCallout', {
duration: formatDistance(new Date(0), payoutDuration),
})}
headingLevel={3}
intent={Intent.Warning}
>
<p className="mb-0">{t('rewardsCalloutDetail')}</p>
</Callout>
</div>
) : null}
{epochData &&
epochData.epoch.id &&
epochData.epoch.timestamps.start &&
epochData.epoch.timestamps.expiry && (
<section className="mb-16">
<EpochCountdown
id={epochData.epoch.id}
startDate={new Date(epochData.epoch.timestamps.start)}
endDate={new Date(epochData.epoch.timestamps.expiry)}
/>
</section>
)}
<section className="grid xl:grid-cols-2 gap-12 items-center mb-8">
<div>
<SubHeading title={t('rewardsAndFeesReceived')} />
<p>
{t(
'ThisDoesNotIncludeFeesReceivedForMakersOrLiquidityProviders'
)}
</p>
</div>
<div className="max-w-[600px]">
<Toggle
name="epoch-reward-view-toggle"
toggles={[
{
label: t('totalDistributed'),
value: 'total',
},
{
label: t('earnedByMe'),
value: 'individual',
},
]}
checkedValue={toggleRewardsView}
onChange={(e) =>
setToggleRewardsView(e.target.value as RewardsView)
}
/>
</div>
</section>
)}
<section>
{pubKey && pubKeys?.length ? (
<RewardInfo currVegaKey={pubKey} data={data} />
) : (
<div>
<Button
data-testid="connect-to-vega-wallet-btn"
onClick={() => {
appDispatch({
type: AppStateActionType.SET_VEGA_WALLET_OVERLAY,
isOpen: true,
});
openVegaWalletDialog();
}}
>
{t('connectVegaWallet')}
</Button>
</div>
)}
</section>
</section>
{toggleRewardsView === 'total' ? (
<EpochRewards />
) : (
<section>
{pubKey && pubKeys?.length ? (
<RewardInfo />
) : (
<div>
<Button
data-testid="connect-to-vega-wallet-btn"
onClick={() => {
appDispatch({
type: AppStateActionType.SET_VEGA_WALLET_OVERLAY,
isOpen: true,
});
openVegaWalletDialog();
}}
>
{t('connectVegaWallet')}
</Button>
</div>
)}
</section>
)}
</section>
)}
/>
);
};

View File

@ -0,0 +1,19 @@
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { SubHeading } from '../../components/heading';
export const NoRewards = () => {
const { t } = useTranslation();
const classes = classNames(
'flex flex-col items-center justify-center h-[300px] w-full',
'border border-vega-dark-200'
);
return (
<div className={classes}>
<SubHeading title={t('noRewardsHaveBeenDistributedYet')} />
<p className="font-alpha text-xl">{t('checkBackSoon')}</p>
</div>
);
};

View File

@ -3,7 +3,7 @@ import { EpochCountdown } from '../../../components/epoch-countdown';
import { useNodesQuery } from './__generated___/Nodes';
import { usePreviousEpochQuery } from '../__generated___/PreviousEpoch';
import { ValidatorTables } from './validator-tables';
import { useRefreshValidators } from '../../../hooks/use-refresh-validators';
import { useRefreshAfterEpoch } from '../../../hooks/use-refresh-after-epoch';
export const EpochData = () => {
// errorPolicy due to vegaprotocol/vega issue 5898
@ -15,7 +15,7 @@ export const EpochData = () => {
skip: !data?.epoch.id,
});
useRefreshValidators(data?.epoch.timestamps.expiry, refetch);
useRefreshAfterEpoch(data?.epoch.timestamps.expiry, refetch);
return (
<AsyncRenderer loading={loading} error={error} data={data}>

View File

@ -2,7 +2,7 @@ import { ENV } from '../../../config';
import { Callout, Intent, Splash } from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useTranslation } from 'react-i18next';
import { useRefreshValidators } from '../../../hooks/use-refresh-validators';
import { useRefreshAfterEpoch } from '../../../hooks/use-refresh-after-epoch';
import { SplashLoader } from '../../../components/splash-loader';
import { useStakingQuery } from './__generated__/Staking';
import { usePreviousEpochQuery } from '../__generated___/PreviousEpoch';
@ -45,7 +45,7 @@ export const NodeContainer = ({
skip: !data?.epoch.id,
});
useRefreshValidators(data?.epoch.timestamps.expiry, refetch);
useRefreshAfterEpoch(data?.epoch.timestamps.expiry, refetch);
if (error) {
return (

View File

@ -8,6 +8,7 @@ export const createDocsLinks = (docsUrl: string) => ({
AUCTION_TYPE_PRICE_MONITORING: `${docsUrl}/concepts/trading-on-vega/trading-modes#auction-type-price-monitoring`,
AUCTION_TYPE_CLOSING: `${docsUrl}/concepts/trading-on-vega/trading-modes#auction-type-closing`,
STAKING_GUIDE: `${docsUrl}/concepts/vega-chain/#staking-on-vega`,
REWARDS_GUIDE: `${docsUrl}/concepts/trading-on-vega/fees-rewards#trading-rewards`,
VEGA_WALLET_CONCEPTS_URL: `${docsUrl}/concepts/vega-wallet`,
PROPOSALS_GUIDE: `${docsUrl}/tutorials/proposals`,
NODE_OPERATORS: `${docsUrl}/node-operators`,