feat(trading): colourful rewards (#5812)

This commit is contained in:
Art 2024-02-20 13:20:35 +01:00 committed by GitHub
parent 9a37572f51
commit be6f395ce4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 769 additions and 605 deletions

View File

@ -2,8 +2,6 @@ import { useT } from '../../lib/use-t';
import { ErrorBoundary } from '@sentry/react'; import { ErrorBoundary } from '@sentry/react';
import { CompetitionsHeader } from '../../components/competitions/competitions-header'; import { CompetitionsHeader } from '../../components/competitions/competitions-header';
import { Intent, Loader, TradingButton } from '@vegaprotocol/ui-toolkit'; import { Intent, Loader, TradingButton } from '@vegaprotocol/ui-toolkit';
import { useGameCards } from '../../lib/hooks/use-game-cards';
import { useEpochInfoQuery } from '../../lib/hooks/__generated__/Epoch'; import { useEpochInfoQuery } from '../../lib/hooks/__generated__/Epoch';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Links } from '../../lib/links'; import { Links } from '../../lib/links';
@ -18,6 +16,7 @@ import take from 'lodash/take';
import { usePageTitle } from '../../lib/hooks/use-page-title'; import { usePageTitle } from '../../lib/hooks/use-page-title';
import { TeamCard } from '../../components/competitions/team-card'; import { TeamCard } from '../../components/competitions/team-card';
import { useMyTeam } from '../../lib/hooks/use-my-team'; import { useMyTeam } from '../../lib/hooks/use-my-team';
import { useRewards } from '../../lib/hooks/use-rewards';
export const CompetitionsHome = () => { export const CompetitionsHome = () => {
const t = useT(); const t = useT();
@ -28,9 +27,9 @@ export const CompetitionsHome = () => {
const { data: epochData } = useEpochInfoQuery(); const { data: epochData } = useEpochInfoQuery();
const currentEpoch = Number(epochData?.epoch.id); const currentEpoch = Number(epochData?.epoch.id);
const { data: gamesData, loading: gamesLoading } = useGameCards({ const { data: gamesData, loading: gamesLoading } = useRewards({
onlyActive: true, onlyActive: true,
currentEpoch, scopeToTeams: true,
}); });
const { data: teamsData, loading: teamsLoading } = useTeams(); const { data: teamsData, loading: teamsLoading } = useTeams();

View File

@ -10,11 +10,7 @@ import {
VegaIcon, VegaIcon,
VegaIconNames, VegaIconNames,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { import { TransferStatus, type Asset } from '@vegaprotocol/types';
TransferStatus,
type Asset,
type RecurringTransfer,
} from '@vegaprotocol/types';
import classNames from 'classnames'; import classNames from 'classnames';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';
import { Table } from '../../components/table'; import { Table } from '../../components/table';
@ -44,11 +40,6 @@ import {
areTeamGames, areTeamGames,
} from '../../lib/hooks/use-games'; } from '../../lib/hooks/use-games';
import { useEpochInfoQuery } from '../../lib/hooks/__generated__/Epoch'; import { useEpochInfoQuery } from '../../lib/hooks/__generated__/Epoch';
import {
type EnrichedTransfer,
isScopedToTeams,
useGameCards,
} from '../../lib/hooks/use-game-cards';
import { useAssetDetailsDialogStore } from '@vegaprotocol/assets'; import { useAssetDetailsDialogStore } from '@vegaprotocol/assets';
import { import {
ActiveRewardCard, ActiveRewardCard,
@ -56,6 +47,11 @@ import {
} from '../../components/rewards-container/active-rewards'; } from '../../components/rewards-container/active-rewards';
import { type MarketMap, useMarketsMapProvider } from '@vegaprotocol/markets'; import { type MarketMap, useMarketsMapProvider } from '@vegaprotocol/markets';
import format from 'date-fns/format'; import format from 'date-fns/format';
import {
type EnrichedRewardTransfer,
isScopedToTeams,
useRewards,
} from '../../lib/hooks/use-rewards';
export const CompetitionsTeam = () => { export const CompetitionsTeam = () => {
const t = useT(); const t = useT();
@ -78,10 +74,9 @@ const TeamPageContainer = ({ teamId }: { teamId: string | undefined }) => {
const { data: games, loading: gamesLoading } = useGames(teamId); const { data: games, loading: gamesLoading } = useGames(teamId);
const { data: epochData, loading: epochLoading } = useEpochInfoQuery(); const { data: transfersData, loading: transfersLoading } = useRewards({
const { data: transfersData, loading: transfersLoading } = useGameCards({
currentEpoch: Number(epochData?.epoch.id),
onlyActive: false, onlyActive: false,
scopeToTeams: true,
}); });
const { data: markets } = useMarketsMapProvider(); const { data: markets } = useMarketsMapProvider();
@ -112,7 +107,7 @@ const TeamPageContainer = ({ teamId }: { teamId: string | undefined }) => {
games={areTeamGames(games) ? games : undefined} games={areTeamGames(games) ? games : undefined}
gamesLoading={gamesLoading} gamesLoading={gamesLoading}
transfers={transfersData} transfers={transfersData}
transfersLoading={epochLoading || transfersLoading} transfersLoading={transfersLoading}
allMarkets={markets || undefined} allMarkets={markets || undefined}
refetch={refetch} refetch={refetch}
/> />
@ -137,7 +132,7 @@ const TeamPage = ({
members?: Member[]; members?: Member[];
games?: TeamGame[]; games?: TeamGame[];
gamesLoading?: boolean; gamesLoading?: boolean;
transfers?: EnrichedTransfer[]; transfers?: EnrichedRewardTransfer[];
transfersLoading?: boolean; transfersLoading?: boolean;
allMarkets?: MarketMap; allMarkets?: MarketMap;
refetch: () => void; refetch: () => void;
@ -211,7 +206,7 @@ const Games = ({
}: { }: {
games?: TeamGame[]; games?: TeamGame[];
gamesLoading?: boolean; gamesLoading?: boolean;
transfers?: EnrichedTransfer[]; transfers?: EnrichedRewardTransfer[];
transfersLoading?: boolean; transfersLoading?: boolean;
allMarkets?: MarketMap; allMarkets?: MarketMap;
}) => { }) => {
@ -451,7 +446,7 @@ const GameTypeCell = ({
transfer, transfer,
allMarkets, allMarkets,
}: { }: {
transfer?: EnrichedTransfer; transfer?: EnrichedRewardTransfer;
allMarkets?: MarketMap; allMarkets?: MarketMap;
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -474,7 +469,7 @@ const GameTypeCell = ({
ref={ref} ref={ref}
className="border-b border-dashed border-vega-clight-200 dark:border-vega-cdark-200 text-left md:truncate md:max-w-[25vw]" className="border-b border-dashed border-vega-clight-200 dark:border-vega-cdark-200 text-left md:truncate md:max-w-[25vw]"
> >
<DispatchMetricInfo transferNode={transfer} allMarkets={allMarkets} /> <DispatchMetricInfo reward={transfer} />
</button> </button>
</> </>
); );
@ -490,7 +485,7 @@ const ActiveRewardCardDialog = ({
open: boolean; open: boolean;
onChange: (isOpen: boolean) => void; onChange: (isOpen: boolean) => void;
trigger?: HTMLElement | null; trigger?: HTMLElement | null;
transfer: EnrichedTransfer; transfer: EnrichedRewardTransfer;
allMarkets?: MarketMap; allMarkets?: MarketMap;
}) => { }) => {
const t = useT(); const t = useT();
@ -516,8 +511,6 @@ const ActiveRewardCardDialog = ({
<ActiveRewardCard <ActiveRewardCard
transferNode={transfer} transferNode={transfer}
currentEpoch={Number(data?.epoch.id)} currentEpoch={Number(data?.epoch.id)}
kind={transfer.transfer.kind as RecurringTransfer}
allMarkets={allMarkets}
/> />
</div> </div>
<div className="w-1/4"> <div className="w-1/4">

View File

@ -1,17 +1,15 @@
import { ActiveRewardCard } from '../rewards-container/active-rewards'; import { ActiveRewardCard } from '../rewards-container/active-rewards';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';
import { type EnrichedTransfer } from '../../lib/hooks/use-game-cards'; import { type EnrichedRewardTransfer } from '../../lib/hooks/use-rewards';
import { useMarketsMapProvider } from '@vegaprotocol/markets';
export const GamesContainer = ({ export const GamesContainer = ({
data, data,
currentEpoch, currentEpoch,
}: { }: {
data: EnrichedTransfer[]; data: EnrichedRewardTransfer[];
currentEpoch: number; currentEpoch: number;
}) => { }) => {
const t = useT(); const t = useT();
const { data: markets } = useMarketsMapProvider();
if (!data || data.length === 0) { if (!data || data.length === 0) {
return ( return (
@ -37,8 +35,6 @@ export const GamesContainer = ({
key={i} key={i}
transferNode={game} transferNode={game}
currentEpoch={currentEpoch} currentEpoch={currentEpoch}
kind={transfer.kind}
allMarkets={markets || undefined}
/> />
); );
})} })}

View File

@ -1,9 +1,5 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { import { ActiveRewardCard, applyFilter } from './active-rewards';
ActiveRewardCard,
applyFilter,
isActiveReward,
} from './active-rewards';
import { import {
AccountType, AccountType,
AssetStatus, AssetStatus,
@ -11,54 +7,13 @@ import {
DistributionStrategy, DistributionStrategy,
EntityScope, EntityScope,
IndividualScope, IndividualScope,
type RecurringTransfer,
type TransferNode,
TransferStatus, TransferStatus,
type Transfer, type Transfer,
} from '@vegaprotocol/types'; } from '@vegaprotocol/types';
import { type EnrichedRewardTransfer } from '../../lib/hooks/use-rewards';
jest.mock('./__generated__/Rewards', () => ({
useMarketForRewardsQuery: () => ({
data: undefined,
}),
}));
jest.mock('@vegaprotocol/assets', () => ({
useAssetDataProvider: () => {
return {
data: {
assetId: 'asset-1',
},
};
},
}));
describe('ActiveRewards', () => { describe('ActiveRewards', () => {
const mockRecurringTransfer: RecurringTransfer = { const reward: EnrichedRewardTransfer = {
__typename: 'RecurringTransfer',
startEpoch: 115332,
endEpoch: 115432,
factor: '1',
dispatchStrategy: {
__typename: 'DispatchStrategy',
dispatchMetric: DispatchMetric.DISPATCH_METRIC_LP_FEES_RECEIVED,
dispatchMetricAssetId:
'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d',
marketIdsInScope: null,
entityScope: EntityScope.ENTITY_SCOPE_INDIVIDUALS,
individualScope: IndividualScope.INDIVIDUAL_SCOPE_ALL,
teamScope: null,
nTopPerformers: '',
stakingRequirement: '',
notionalTimeWeightedAveragePositionRequirement: '',
windowLength: 1,
lockPeriod: 0,
distributionStrategy: DistributionStrategy.DISTRIBUTION_STRATEGY_PRO_RATA,
rankTable: null,
},
};
const mockTransferNode: TransferNode = {
__typename: 'TransferNode', __typename: 'TransferNode',
transfer: { transfer: {
__typename: 'Transfer', __typename: 'Transfer',
@ -86,21 +41,37 @@ describe('ActiveRewards', () => {
reference: 'reward', reference: 'reward',
status: TransferStatus.STATUS_PENDING, status: TransferStatus.STATUS_PENDING,
timestamp: '2023-12-18T13:05:35.948706Z', timestamp: '2023-12-18T13:05:35.948706Z',
kind: mockRecurringTransfer, kind: {
__typename: 'RecurringTransfer',
startEpoch: 115332,
endEpoch: 115432,
factor: '1',
dispatchStrategy: {
__typename: 'DispatchStrategy',
dispatchMetric: DispatchMetric.DISPATCH_METRIC_LP_FEES_RECEIVED,
dispatchMetricAssetId:
'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d',
marketIdsInScope: null,
entityScope: EntityScope.ENTITY_SCOPE_INDIVIDUALS,
individualScope: IndividualScope.INDIVIDUAL_SCOPE_ALL,
teamScope: null,
nTopPerformers: '',
stakingRequirement: '',
notionalTimeWeightedAveragePositionRequirement: '',
windowLength: 1,
lockPeriod: 0,
distributionStrategy:
DistributionStrategy.DISTRIBUTION_STRATEGY_PRO_RATA,
rankTable: null,
},
},
reason: null, reason: null,
}, },
fees: [], fees: [],
}; };
it('renders with valid props', () => { it('renders with valid props', () => {
render( render(<ActiveRewardCard transferNode={reward} currentEpoch={115432} />);
<ActiveRewardCard
transferNode={mockTransferNode}
currentEpoch={1}
kind={mockRecurringTransfer}
allMarkets={{}}
/>
);
expect( expect(
screen.getByText(/Liquidity provision fees received/i) screen.getByText(/Liquidity provision fees received/i)
@ -108,41 +79,11 @@ describe('ActiveRewards', () => {
expect(screen.getByText('Individual scope')).toBeInTheDocument(); expect(screen.getByText('Individual scope')).toBeInTheDocument();
expect(screen.getByText('Average position')).toBeInTheDocument(); expect(screen.getByText('Average position')).toBeInTheDocument();
expect(screen.getByText('Ends in')).toBeInTheDocument(); expect(screen.getByText('Ends in')).toBeInTheDocument();
expect(screen.getByText('115431 epochs')).toBeInTheDocument(); expect(screen.getByText('1 epoch')).toBeInTheDocument();
expect(screen.getByText('Assessed over')).toBeInTheDocument(); expect(screen.getByText('Assessed over')).toBeInTheDocument();
expect(screen.getByText('1 epoch')).toBeInTheDocument(); expect(screen.getByText('1 epoch')).toBeInTheDocument();
}); });
describe('isActiveReward', () => {
it('returns true for valid active reward', () => {
const node = {
transfer: {
kind: {
__typename: 'RecurringTransfer',
dispatchStrategy: {},
endEpoch: 10,
},
status: TransferStatus.STATUS_PENDING,
},
} as TransferNode;
expect(isActiveReward(node, 5)).toBeTruthy();
});
it('returns false for invalid active reward', () => {
const node = {
transfer: {
kind: {
__typename: 'RecurringTransfer',
dispatchStrategy: {},
endEpoch: 10,
},
status: TransferStatus.STATUS_PENDING,
},
} as TransferNode;
expect(isActiveReward(node, 15)).toBeFalsy();
});
});
describe('applyFilter', () => { describe('applyFilter', () => {
it('returns true when filter matches dispatch metric label', () => { it('returns true when filter matches dispatch metric label', () => {
const transfer = { const transfer = {

View File

@ -1,12 +1,8 @@
import { useActiveRewardsQuery } from './__generated__/Rewards';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils'; import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
type IconName,
type VegaIconSize, type VegaIconSize,
Icon,
Intent,
Tooltip, Tooltip,
VegaIcon, VegaIcon,
VegaIconNames, VegaIconNames,
@ -14,18 +10,12 @@ import {
TinyScroll, TinyScroll,
truncateMiddle, truncateMiddle,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
import { IconNames } from '@blueprintjs/icons';
import { import {
type Maybe,
type Transfer,
type TransferNode, type TransferNode,
type RecurringTransfer,
DistributionStrategyDescriptionMapping, DistributionStrategyDescriptionMapping,
DistributionStrategyMapping, DistributionStrategyMapping,
EntityScope, EntityScope,
EntityScopeMapping, EntityScopeMapping,
TransferStatus,
TransferStatusMapping,
DispatchMetric, DispatchMetric,
DispatchMetricDescription, DispatchMetricDescription,
DispatchMetricLabels, DispatchMetricLabels,
@ -36,43 +26,33 @@ import {
IndividualScopeDescriptionMapping, IndividualScopeDescriptionMapping,
} from '@vegaprotocol/types'; } from '@vegaprotocol/types';
import { Card } from '../card/card'; import { Card } from '../card/card';
import { useMemo, useState } from 'react'; import { type ReactNode, useState } from 'react';
import { import {
type AssetFieldsFragment, type AssetFieldsFragment,
useAssetsMapProvider, type BasicAssetDetails,
} from '@vegaprotocol/assets'; } from '@vegaprotocol/assets';
import { type MarketFieldsFragment } from '@vegaprotocol/markets';
import { import {
type MarketFieldsFragment, type EnrichedRewardTransfer,
useMarketsMapProvider, useRewards,
getAsset, } from '../../lib/hooks/use-rewards';
} from '@vegaprotocol/markets'; import compact from 'lodash/compact';
enum CardColour {
BLUE = 'BLUE',
GREEN = 'GREEN',
GREY = 'GREY',
ORANGE = 'ORANGE',
PINK = 'PINK',
PURPLE = 'PURPLE',
WHITE = 'WHITE',
YELLOW = 'YELLOW',
}
export type Filter = { export type Filter = {
searchTerm: string; searchTerm: string;
}; };
export const isActiveReward = (node: TransferNode, currentEpoch: number) => {
const { transfer } = node;
if (transfer.kind.__typename !== 'RecurringTransfer') {
return false;
}
const { dispatchStrategy } = transfer.kind;
if (!dispatchStrategy) {
return false;
}
if (transfer.kind.endEpoch && transfer.kind.endEpoch < currentEpoch) {
return false;
}
if (transfer.status !== TransferStatus.STATUS_PENDING) {
return false;
}
return true;
};
export const applyFilter = ( export const applyFilter = (
node: TransferNode & { node: TransferNode & {
asset?: AssetFieldsFragment | null; asset?: AssetFieldsFragment | null;
@ -95,7 +75,10 @@ export const applyFilter = (
transfer.asset?.symbol transfer.asset?.symbol
.toLowerCase() .toLowerCase()
.includes(filter.searchTerm.toLowerCase()) || .includes(filter.searchTerm.toLowerCase()) ||
EntityScopeLabelMapping[transfer.kind.dispatchStrategy.entityScope] (
EntityScopeLabelMapping[transfer.kind.dispatchStrategy.entityScope] ||
'Unspecified'
)
.toLowerCase() .toLowerCase()
.includes(filter.searchTerm.toLowerCase()) || .includes(filter.searchTerm.toLowerCase()) ||
node.asset?.name node.asset?.name
@ -114,42 +97,15 @@ export const applyFilter = (
export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => { export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
const t = useT(); const t = useT();
const { data: activeRewardsData } = useActiveRewardsQuery({ const { data } = useRewards({
variables: { onlyActive: true,
isReward: true,
},
}); });
const [filter, setFilter] = useState<Filter>({ const [filter, setFilter] = useState<Filter>({
searchTerm: '', searchTerm: '',
}); });
const { data: assets } = useAssetsMapProvider(); if (!data || !data.length) return null;
const { data: markets } = useMarketsMapProvider();
const enrichedTransfers = activeRewardsData?.transfersConnection?.edges
?.map((e) => e?.node as TransferNode)
.filter((node) => isActiveReward(node, currentEpoch))
.map((node) => {
if (node.transfer.kind.__typename !== 'RecurringTransfer') {
return node;
}
const asset =
assets &&
assets[
node.transfer.kind.dispatchStrategy?.dispatchMetricAssetId || ''
];
const marketsInScope =
node.transfer.kind.dispatchStrategy?.marketIdsInScope?.map(
(id) => markets && markets[id]
);
return { ...node, asset, markets: marketsInScope };
});
if (!enrichedTransfers || !enrichedTransfers.length) return null;
return ( return (
<Card <Card
@ -157,7 +113,8 @@ export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
className="lg:col-span-full" className="lg:col-span-full"
data-testid="active-rewards-card" data-testid="active-rewards-card"
> >
{enrichedTransfers.length > 1 && ( {/** CARDS FILTER */}
{data.length > 1 && (
<TradingInput <TradingInput
onChange={(e) => onChange={(e) =>
setFilter((curr) => ({ ...curr, searchTerm: e.target.value })) setFilter((curr) => ({ ...curr, searchTerm: e.target.value }))
@ -172,142 +129,32 @@ export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
prependElement={<VegaIcon name={VegaIconNames.SEARCH} />} prependElement={<VegaIcon name={VegaIconNames.SEARCH} />}
/> />
)} )}
{/** CARDS */}
<TinyScroll className="grid gap-x-8 gap-y-10 h-fit grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] md:grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] lg:grid-cols-[repeat(auto-fill,_minmax(320px,_1fr))] xl:grid-cols-[repeat(auto-fill,_minmax(335px,_1fr))] max-h-[40rem] overflow-auto pr-2"> <TinyScroll className="grid gap-x-8 gap-y-10 h-fit grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] md:grid-cols-[repeat(auto-fill,_minmax(230px,_1fr))] lg:grid-cols-[repeat(auto-fill,_minmax(320px,_1fr))] xl:grid-cols-[repeat(auto-fill,_minmax(335px,_1fr))] max-h-[40rem] overflow-auto pr-2">
{enrichedTransfers {data
.filter((n) => applyFilter(n, filter)) .filter((n) => applyFilter(n, filter))
.map((node, i) => { .map((node, i) => (
const { transfer } = node;
if (
transfer.kind.__typename !== 'RecurringTransfer' ||
!transfer.kind.dispatchStrategy?.dispatchMetric
) {
return null;
}
return (
node && (
<ActiveRewardCard <ActiveRewardCard
key={i} key={i}
transferNode={node} transferNode={node}
kind={transfer.kind}
currentEpoch={currentEpoch} currentEpoch={currentEpoch}
allMarkets={markets || {}}
/> />
) ))}
);
})}
</TinyScroll> </TinyScroll>
</Card> </Card>
); );
}; };
// This was built to be a status indicator for the rewards based on the transfer status
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const StatusIndicator = ({
status,
reason,
}: {
status: TransferStatus;
reason?: Maybe<string> | undefined;
}) => {
const t = useT();
const getIconIntent = (status: string) => {
switch (status) {
case TransferStatus.STATUS_DONE:
return { icon: IconNames.TICK_CIRCLE, intent: Intent.Success };
case TransferStatus.STATUS_REJECTED:
return { icon: IconNames.ERROR, intent: Intent.Danger };
default:
return { icon: IconNames.HELP, intent: Intent.Primary };
}
};
const { icon, intent } = getIconIntent(status);
return (
<Tooltip
description={
<span>
{t('Transfer status: {{status}} {{reason}}', {
status: TransferStatusMapping[status],
reason: reason ? `(${reason})` : '',
})}
</span>
}
>
<span
className={classNames(
{
'text-gray-700 dark:text-gray-300': intent === Intent.None,
'text-vega-blue': intent === Intent.Primary,
'text-vega-green dark:text-vega-green': intent === Intent.Success,
'dark:text-yellow text-yellow-600': intent === Intent.Warning,
'text-vega-red': intent === Intent.Danger,
},
'flex items-start p-1 align-text-bottom'
)}
>
<Icon size={3} name={icon as IconName} />
</span>
</Tooltip>
);
};
type ActiveRewardCardProps = { type ActiveRewardCardProps = {
transferNode: TransferNode & { transferNode: EnrichedRewardTransfer;
asset?: AssetFieldsFragment | null;
markets?: (MarketFieldsFragment | null)[];
};
currentEpoch: number; currentEpoch: number;
kind: RecurringTransfer;
allMarkets?: Record<string, MarketFieldsFragment | null>;
}; };
export const ActiveRewardCard = ({ export const ActiveRewardCard = ({
transferNode, transferNode,
currentEpoch, currentEpoch,
kind,
allMarkets,
}: ActiveRewardCardProps) => { }: ActiveRewardCardProps) => {
const t = useT(); // don't display the cards that are scoped to not trading markets
const marketSettled = transferNode.markets?.filter(
const { transfer } = transferNode;
const { dispatchStrategy } = kind;
const marketIdsInScope = dispatchStrategy?.marketIdsInScope;
const firstMarketData = transferNode.markets?.[0];
const specificMarkets = useMemo(() => {
if (
!firstMarketData ||
!marketIdsInScope ||
marketIdsInScope.length === 0
) {
return null;
}
if (marketIdsInScope.length > 1) {
const marketNames =
allMarkets &&
marketIdsInScope
.map((id) => allMarkets[id]?.tradableInstrument?.instrument?.name)
.join(', ');
return (
<Tooltip description={marketNames}>
<span>Specific markets</span>
</Tooltip>
);
}
return (
<span>{firstMarketData?.tradableInstrument?.instrument?.name || ''}</span>
);
}, [firstMarketData, marketIdsInScope, allMarkets]);
const dispatchAsset = transferNode.asset;
if (!dispatchStrategy) {
return null;
}
// Gray out/hide the cards that are related to not trading markets
const marketSettled = transferNode.markets?.some(
(m) => (m) =>
m?.state && m?.state &&
[ [
@ -318,78 +165,114 @@ export const ActiveRewardCard = ({
].includes(m.state) ].includes(m.state)
); );
if (marketSettled) { // hide the card if all of the markets are being marked as e.g. settled
if (
marketSettled?.length === transferNode.markets?.length &&
Boolean(transferNode.markets && transferNode.markets.length > 0)
) {
return null; return null;
} }
const assetInActiveMarket = let colour =
allMarkets && DispatchMetricColourMap[
Object.values(allMarkets).some((m: MarketFieldsFragment | null) => { transferNode.transfer.kind.dispatchStrategy.dispatchMetric
if (m && getAsset(m).id === dispatchStrategy.dispatchMetricAssetId) { ];
return m?.state && MarketState.STATE_ACTIVE === m.state;
}
return false;
});
const marketSuspended = transferNode.markets?.some( // grey out of any of the markets is suspended or
// if the asset is not currently traded on any of the active markets
const marketSuspended =
transferNode.markets?.filter(
(m) => (m) =>
m?.state === MarketState.STATE_SUSPENDED || m?.state === MarketState.STATE_SUSPENDED ||
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
); ).length === transferNode.markets?.length &&
Boolean(transferNode.markets && transferNode.markets.length > 0);
// Gray out the cards that are related to suspended markets if (marketSuspended || !transferNode.isAssetTraded) {
// Or settlement assets in markets that are not active and eligible for rewards colour = CardColour.GREY;
const { gradientClassName, mainClassName } =
marketSuspended || !assetInActiveMarket
? {
gradientClassName: 'from-vega-cdark-500 to-vega-clight-400',
mainClassName: 'from-vega-cdark-400 dark:from-vega-cdark-600 to-20%',
} }
: getGradientClasses(dispatchStrategy.dispatchMetric);
const entityScope = dispatchStrategy.entityScope; return (
<RewardCard
colour={colour}
rewardAmount={addDecimalsFormatNumber(
transferNode.transfer.amount,
transferNode.transfer.asset?.decimals || 0,
6
)}
rewardAsset={transferNode.asset}
endsIn={
transferNode.transfer.kind.endEpoch != null
? transferNode.transfer.kind.endEpoch - currentEpoch
: undefined
}
dispatchStrategy={transferNode.transfer.kind.dispatchStrategy}
dispatchMetricInfo={<DispatchMetricInfo reward={transferNode} />}
/>
);
};
const RewardCard = ({
colour,
rewardAmount,
rewardAsset,
dispatchStrategy,
endsIn,
dispatchMetricInfo,
}: {
colour: CardColour;
rewardAmount: string;
/** The asset linked to the dispatch strategy via `dispatchMetricAssetId` property. */
rewardAsset?: BasicAssetDetails;
/** The transfer's dispatch strategy. */
dispatchStrategy: DispatchStrategy;
/** The number of epochs until the transfer stops. */
endsIn?: number;
/** The VEGA asset details, required to format the min staking amount. */
vegaAsset?: BasicAssetDetails;
dispatchMetricInfo?: ReactNode;
}) => {
const t = useT();
return ( return (
<div> <div>
<div <div
className={classNames( className={classNames(
'bg-gradient-to-r col-span-full p-0.5 lg:col-auto h-full', 'bg-gradient-to-r col-span-full p-0.5 lg:col-auto h-full',
'rounded-lg', 'rounded-lg',
gradientClassName CardColourStyles[colour].gradientClassName
)} )}
data-testid="active-rewards-card" data-testid="active-rewards-card"
> >
<div <div
className={classNames( className={classNames(
mainClassName, CardColourStyles[colour].mainClassName,
'bg-gradient-to-b bg-vega-clight-800 dark:bg-vega-cdark-800 h-full w-full rounded p-4 flex flex-col gap-4' 'bg-gradient-to-b bg-vega-clight-800 dark:bg-vega-cdark-800 h-full w-full rounded-md p-4 flex flex-col gap-4'
)} )}
> >
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
{/** ENTITY SCOPE */}
<div className="flex flex-col gap-2 items-center text-center"> <div className="flex flex-col gap-2 items-center text-center">
<EntityIcon transfer={transfer} /> <EntityIcon entityScope={dispatchStrategy.entityScope} />
{entityScope && ( {dispatchStrategy.entityScope && (
<span className="text-muted text-xs" data-testid="entity-scope"> <span className="text-muted text-xs" data-testid="entity-scope">
{EntityScopeLabelMapping[entityScope] || t('Unspecified')} {EntityScopeLabelMapping[dispatchStrategy.entityScope] ||
t('Unspecified')}
</span> </span>
)} )}
</div> </div>
{/** AMOUNT AND DISTRIBUTION STRATEGY */}
<div className="flex flex-col gap-2 items-center text-center"> <div className="flex flex-col gap-2 items-center text-center">
{/** AMOUNT */}
<h3 className="flex flex-col gap-1 text-2xl shrink-1 text-center"> <h3 className="flex flex-col gap-1 text-2xl shrink-1 text-center">
<span className="font-glitch" data-testid="reward-value"> <span className="font-glitch" data-testid="reward-value">
{addDecimalsFormatNumber( {rewardAmount}
transferNode.transfer.amount,
transferNode.transfer.asset?.decimals || 0,
6
)}
</span> </span>
<span className="font-alpha"> <span className="font-alpha">{rewardAsset?.symbol || ''}</span>
{transferNode.transfer.asset?.symbol}
</span>
</h3> </h3>
{
{/** DISTRIBUTION STRATEGY */}
<Tooltip <Tooltip
description={t( description={t(
DistributionStrategyDescriptionMapping[ DistributionStrategyDescriptionMapping[
@ -406,9 +289,9 @@ export const ActiveRewardCard = ({
} }
</span> </span>
</Tooltip> </Tooltip>
}
</div> </div>
{/** DISTRIBUTION DELAY */}
<div className="flex flex-col gap-2 items-center text-center"> <div className="flex flex-col gap-2 items-center text-center">
<CardIcon <CardIcon
iconName={VegaIconNames.LOCK} iconName={VegaIconNames.LOCK}
@ -421,43 +304,38 @@ export const ActiveRewardCard = ({
data-testid="locked-for" data-testid="locked-for"
> >
{t('numberEpochs', '{{count}} epochs', { {t('numberEpochs', '{{count}} epochs', {
count: kind.dispatchStrategy?.lockPeriod, count: dispatchStrategy.lockPeriod,
})} })}
</span> </span>
</div> </div>
</div> </div>
<span className="border-[0.5px] border-gray-700" /> <span className="border-[0.5px] border-gray-700" />
{/** DISPATCH METRIC */}
{dispatchMetricInfo ? (
dispatchMetricInfo
) : (
<span data-testid="dispatch-metric-info"> <span data-testid="dispatch-metric-info">
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]} {' '} {DispatchMetricLabels[dispatchStrategy.dispatchMetric]}
<Tooltip
underline={marketSuspended}
description={
(marketSuspended || !assetInActiveMarket) &&
(specificMarkets
? t('Eligible market(s) currently suspended')
: !assetInActiveMarket
? t('Currently no markets eligible for reward')
: '')
}
>
<span>{specificMarkets || dispatchAsset?.name}</span>
</Tooltip>
</span> </span>
)}
<div className="flex items-center gap-8 flex-wrap"> <div className="flex items-center gap-8 flex-wrap">
{kind.endEpoch && ( {/** ENDS IN */}
{endsIn != null && (
<span className="flex flex-col"> <span className="flex flex-col">
<span className="text-muted text-xs">{t('Ends in')} </span> <span className="text-muted text-xs">{t('Ends in')} </span>
<span data-testid="ends-in"> <span data-testid="ends-in" data-endsin={endsIn}>
{t('numberEpochs', '{{count}} epochs', { {endsIn >= 0
count: kind.endEpoch - currentEpoch, ? t('numberEpochs', '{{count}} epochs', {
})} count: endsIn,
})
: t('Ended')}
</span> </span>
</span> </span>
)} )}
{ {/** WINDOW LENGTH */}
<span className="flex flex-col"> <span className="flex flex-col">
<span className="text-muted text-xs">{t('Assessed over')}</span> <span className="text-muted text-xs">{t('Assessed over')}</span>
<span data-testid="assessed-over"> <span data-testid="assessed-over">
@ -466,18 +344,19 @@ export const ActiveRewardCard = ({
})} })}
</span> </span>
</span> </span>
}
</div> </div>
{/** DISPATCH METRIC DESCRIPTION */}
{dispatchStrategy?.dispatchMetric && ( {dispatchStrategy?.dispatchMetric && (
<span className="text-muted text-sm h-[3rem]"> <span className="text-muted text-sm h-[3rem]">
{t(DispatchMetricDescription[dispatchStrategy?.dispatchMetric])} {t(DispatchMetricDescription[dispatchStrategy?.dispatchMetric])}
</span> </span>
)} )}
<span className="border-[0.5px] border-gray-700" /> <span className="border-[0.5px] border-gray-700" />
{kind.dispatchStrategy && ( {/** REQUIREMENTS */}
{dispatchStrategy && (
<RewardRequirements <RewardRequirements
dispatchStrategy={kind.dispatchStrategy} dispatchStrategy={dispatchStrategy}
assetDecimalPlaces={transfer.asset?.decimals} rewardAsset={rewardAsset}
/> />
)} )}
</div> </div>
@ -487,77 +366,67 @@ export const ActiveRewardCard = ({
}; };
export const DispatchMetricInfo = ({ export const DispatchMetricInfo = ({
transferNode, reward,
allMarkets,
}: { }: {
transferNode: ActiveRewardCardProps['transferNode']; reward: EnrichedRewardTransfer;
allMarkets?: ActiveRewardCardProps['allMarkets'];
}) => { }) => {
const dispatchStrategy = const t = useT();
transferNode.transfer.kind.__typename === 'RecurringTransfer' const dispatchStrategy = reward.transfer.kind.dispatchStrategy;
? transferNode.transfer.kind.dispatchStrategy const marketNames = compact(
: null; reward.markets?.map((m) => m.tradableInstrument.instrument.name)
);
const dispatchAsset = transferNode.transfer.asset; let additionalDispatchMetricInfo = null;
const marketIdsInScope = dispatchStrategy?.marketIdsInScope; // if asset found then display asset symbol
const firstMarketData = transferNode.markets?.[0]; if (reward.asset) {
const specificMarkets = useMemo(() => { additionalDispatchMetricInfo = <span>{reward.asset.symbol}</span>;
if (
!firstMarketData ||
!marketIdsInScope ||
marketIdsInScope.length === 0
) {
return null;
} }
if (marketIdsInScope.length > 1) { // but if scoped to only one market then display market name
const marketNames = if (marketNames.length === 1) {
allMarkets && additionalDispatchMetricInfo = <span>{marketNames[0]}</span>;
marketIdsInScope }
.map((id) => allMarkets[id]?.tradableInstrument?.instrument?.name) // or if scoped to many markets then indicate it's scoped to "specific markets"
.join(', '); if (marketNames.length > 1) {
additionalDispatchMetricInfo = (
return (
<Tooltip description={marketNames}> <Tooltip description={marketNames}>
<span>Specific markets</span> <span>{t('Specific markets')}</span>
</Tooltip> </Tooltip>
); );
} }
const name = firstMarketData?.tradableInstrument?.instrument?.name;
if (name) {
return <span>{name}</span>;
}
return null;
}, [firstMarketData, marketIdsInScope, allMarkets]);
if (!dispatchStrategy) return null;
return ( return (
<span data-testid="dispatch-metric-info"> <span data-testid="dispatch-metric-info">
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]} {' '} {DispatchMetricLabels[dispatchStrategy.dispatchMetric]}
<span>{specificMarkets || dispatchAsset?.name}</span> {additionalDispatchMetricInfo != null && (
<> {additionalDispatchMetricInfo}</>
)}
</span> </span>
); );
}; };
const RewardRequirements = ({ const RewardRequirements = ({
dispatchStrategy, dispatchStrategy,
assetDecimalPlaces = 0, rewardAsset,
vegaAsset,
}: { }: {
dispatchStrategy: DispatchStrategy; dispatchStrategy: DispatchStrategy;
assetDecimalPlaces: number | undefined; rewardAsset?: BasicAssetDetails;
vegaAsset?: BasicAssetDetails;
}) => { }) => {
const t = useT(); const t = useT();
const entityLabel = EntityScopeLabelMapping[dispatchStrategy.entityScope];
return ( return (
<dl className="flex justify-between flex-wrap items-center gap-3 text-xs"> <dl className="flex justify-between flex-wrap items-center gap-3 text-xs">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<dt className="flex items-center gap-1 text-muted"> <dt className="flex items-center gap-1 text-muted">
{t('{{entity}} scope', { {entityLabel
entity: EntityScopeLabelMapping[dispatchStrategy.entityScope], ? t('{{entity}} scope', {
})} entity: entityLabel,
})
: t('Scope')}
</dt> </dt>
<dd className="flex items-center gap-1" data-testid="scope"> <dd className="flex items-center gap-1" data-testid="scope">
<RewardEntityScope dispatchStrategy={dispatchStrategy} /> <RewardEntityScope dispatchStrategy={dispatchStrategy} />
@ -574,7 +443,7 @@ const RewardRequirements = ({
> >
{addDecimalsFormatNumber( {addDecimalsFormatNumber(
dispatchStrategy?.stakingRequirement || 0, dispatchStrategy?.stakingRequirement || 0,
assetDecimalPlaces vegaAsset?.decimals || 18
)} )}
</dd> </dd>
</div> </div>
@ -587,7 +456,7 @@ const RewardRequirements = ({
{addDecimalsFormatNumber( {addDecimalsFormatNumber(
dispatchStrategy?.notionalTimeWeightedAveragePositionRequirement || dispatchStrategy?.notionalTimeWeightedAveragePositionRequirement ||
0, 0,
assetDecimalPlaces rewardAsset?.decimals || 0
)} )}
</dd> </dd>
</div> </div>
@ -645,44 +514,65 @@ const RewardEntityScope = ({
); );
} }
return null; return t('Unspecified');
}; };
const getGradientClasses = (d: DispatchMetric | undefined) => { const CardColourStyles: Record<
switch (d) { CardColour,
case DispatchMetric.DISPATCH_METRIC_AVERAGE_POSITION: { gradientClassName: string; mainClassName: string }
return { > = {
gradientClassName: 'from-vega-pink-500 to-vega-purple-400', [CardColour.BLUE]: {
mainClassName: 'from-vega-pink-400 dark:from-vega-pink-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_LP_FEES_RECEIVED:
return {
gradientClassName: 'from-vega-green-500 to-vega-yellow-500',
mainClassName: 'from-vega-green-400 dark:from-vega-green-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_MAKER_FEES_PAID:
return {
gradientClassName: 'from-vega-orange-500 to-vega-pink-400',
mainClassName: 'from-vega-orange-400 dark:from-vega-orange-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_MARKET_VALUE:
case DispatchMetric.DISPATCH_METRIC_RELATIVE_RETURN:
return {
gradientClassName: 'from-vega-purple-500 to-vega-blue-400',
mainClassName: 'from-vega-purple-400 dark:from-vega-purple-600 to-20%',
};
case DispatchMetric.DISPATCH_METRIC_RETURN_VOLATILITY:
return {
gradientClassName: 'from-vega-blue-500 to-vega-green-400', gradientClassName: 'from-vega-blue-500 to-vega-green-400',
mainClassName: 'from-vega-blue-400 dark:from-vega-blue-600 to-20%', mainClassName: 'from-vega-blue-400 dark:from-vega-blue-600 to-20%',
}; },
case DispatchMetric.DISPATCH_METRIC_VALIDATOR_RANKING: [CardColour.GREEN]: {
default: gradientClassName: 'from-vega-green-500 to-vega-yellow-500',
return { mainClassName: 'from-vega-green-400 dark:from-vega-green-600 to-20%',
},
[CardColour.GREY]: {
gradientClassName: 'from-vega-cdark-500 to-vega-clight-200',
mainClassName: 'from-vega-cdark-400 dark:from-vega-cdark-600 to-20%',
},
[CardColour.ORANGE]: {
gradientClassName: 'from-vega-orange-500 to-vega-pink-400',
mainClassName: 'from-vega-orange-400 dark:from-vega-orange-600 to-20%',
},
[CardColour.PINK]: {
gradientClassName: 'from-vega-pink-500 to-vega-purple-400', gradientClassName: 'from-vega-pink-500 to-vega-purple-400',
mainClassName: 'from-vega-pink-400 dark:from-vega-pink-600 to-20%', mainClassName: 'from-vega-pink-400 dark:from-vega-pink-600 to-20%',
},
[CardColour.PURPLE]: {
gradientClassName: 'from-vega-purple-500 to-vega-blue-400',
mainClassName: 'from-vega-purple-400 dark:from-vega-purple-600 to-20%',
},
[CardColour.WHITE]: {
gradientClassName:
'from-vega-clight-600 dark:from-vega-clight-900 to-vega-yellow-500 dark:to-vega-yellow-400',
mainClassName: 'from-white dark:from-vega-clight-100 to-20%',
},
[CardColour.YELLOW]: {
gradientClassName: 'from-vega-yellow-500 to-vega-orange-400',
mainClassName: 'from-vega-yellow-400 dark:from-vega-yellow-600 to-20%',
},
}; };
}
const DispatchMetricColourMap: Record<DispatchMetric, CardColour> = {
// Liquidity provision fees received
[DispatchMetric.DISPATCH_METRIC_LP_FEES_RECEIVED]: CardColour.BLUE,
// Price maker fees paid
[DispatchMetric.DISPATCH_METRIC_MAKER_FEES_PAID]: CardColour.PINK,
// Price maker fees earned
[DispatchMetric.DISPATCH_METRIC_MAKER_FEES_RECEIVED]: CardColour.GREEN,
// Total market value
[DispatchMetric.DISPATCH_METRIC_MARKET_VALUE]: CardColour.WHITE,
// Average position
[DispatchMetric.DISPATCH_METRIC_AVERAGE_POSITION]: CardColour.ORANGE,
// Relative return
[DispatchMetric.DISPATCH_METRIC_RELATIVE_RETURN]: CardColour.PURPLE,
// Return volatility
[DispatchMetric.DISPATCH_METRIC_RETURN_VOLATILITY]: CardColour.YELLOW,
// Validator ranking
[DispatchMetric.DISPATCH_METRIC_VALIDATOR_RANKING]: CardColour.WHITE,
}; };
const CardIcon = ({ const CardIcon = ({
@ -703,36 +593,29 @@ const CardIcon = ({
); );
}; };
const EntityScopeIconMap: Record<EntityScope, VegaIconNames> = {
[EntityScope.ENTITY_SCOPE_TEAMS]: VegaIconNames.TEAM,
[EntityScope.ENTITY_SCOPE_INDIVIDUALS]: VegaIconNames.MAN,
};
const EntityIcon = ({ const EntityIcon = ({
transfer, entityScope,
size = 18, size = 18,
}: { }: {
transfer: Transfer; entityScope: EntityScope;
size?: VegaIconSize; size?: VegaIconSize;
}) => { }) => {
if (transfer.kind.__typename !== 'RecurringTransfer') {
return null;
}
const entityScope = transfer.kind.dispatchStrategy?.entityScope;
const getIconName = () => {
switch (entityScope) {
case EntityScope.ENTITY_SCOPE_TEAMS:
return VegaIconNames.TEAM;
case EntityScope.ENTITY_SCOPE_INDIVIDUALS:
return VegaIconNames.MAN;
default:
return VegaIconNames.QUESTION_MARK;
}
};
const iconName = getIconName();
return ( return (
<Tooltip <Tooltip
description={ description={
<span>{entityScope ? EntityScopeMapping[entityScope] : ''}</span> entityScope ? <span>{EntityScopeMapping[entityScope]}</span> : undefined
} }
> >
<span className="flex items-center p-2 rounded-full border border-gray-600"> <span className="flex items-center p-2 rounded-full border border-gray-600">
{iconName && <VegaIcon name={iconName} size={size} />} <VegaIcon
name={EntityScopeIconMap[entityScope] || VegaIconNames.QUESTION_MARK}
size={size}
/>
</span> </span>
</Tooltip> </Tooltip>
); );

View File

@ -20,7 +20,7 @@ import {
type RewardsPageQuery, type RewardsPageQuery,
useRewardsPageQuery, useRewardsPageQuery,
useRewardsEpochQuery, useRewardsEpochQuery,
} from './__generated__/Rewards'; } from '../../lib/hooks/__generated__/Rewards';
import { import {
TradingButton, TradingButton,
VegaIcon, VegaIcon,

View File

@ -16,7 +16,7 @@ import {
import { import {
useRewardsHistoryQuery, useRewardsHistoryQuery,
type RewardsHistoryQuery, type RewardsHistoryQuery,
} from './__generated__/Rewards'; } from '../../lib/hooks/__generated__/Rewards';
import { useRewardsRowData } from './use-reward-row-data'; import { useRewardsRowData } from './use-reward-row-data';
import { useT } from '../../lib/use-t'; import { useT } from '../../lib/use-t';

View File

@ -4,7 +4,7 @@ import BigNumber from 'bignumber.js';
import { removePaginationWrapper } from '@vegaprotocol/utils'; import { removePaginationWrapper } from '@vegaprotocol/utils';
import { type Asset } from '@vegaprotocol/assets'; import { type Asset } from '@vegaprotocol/assets';
import { type PartyRewardsConnection } from './rewards-history'; import { type PartyRewardsConnection } from './rewards-history';
import { type RewardsHistoryQuery } from './__generated__/Rewards'; import { type RewardsHistoryQuery } from '../../lib/hooks/__generated__/Rewards';
const REWARD_ACCOUNT_TYPES = [ const REWARD_ACCOUNT_TYPES = [
AccountType.ACCOUNT_TYPE_GLOBAL_REWARD, AccountType.ACCOUNT_TYPE_GLOBAL_REWARD,

View File

@ -1,92 +0,0 @@
import compact from 'lodash/compact';
import { useActiveRewardsQuery } from '../../components/rewards-container/__generated__/Rewards';
import { isActiveReward } from '../../components/rewards-container/active-rewards';
import {
type RecurringTransfer,
type TransferNode,
EntityScope,
IndividualScope,
} from '@vegaprotocol/types';
import {
type AssetFieldsFragment,
useAssetsMapProvider,
} from '@vegaprotocol/assets';
import {
type MarketFieldsFragment,
useMarketsMapProvider,
} from '@vegaprotocol/markets';
import { type ApolloError } from '@apollo/client';
export type EnrichedTransfer = TransferNode & {
asset?: AssetFieldsFragment | null;
markets?: (MarketFieldsFragment | null)[];
};
type RecurringTransferKind = EnrichedTransfer & {
transfer: {
kind: RecurringTransfer;
};
};
export const isScopedToTeams = (
node: TransferNode
): node is RecurringTransferKind =>
node.transfer.kind.__typename === 'RecurringTransfer' &&
// scoped to teams
(node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_TEAMS ||
// or to individuals
(node.transfer.kind.dispatchStrategy?.entityScope ===
EntityScope.ENTITY_SCOPE_INDIVIDUALS &&
// but they have to be in a team
node.transfer.kind.dispatchStrategy.individualScope ===
IndividualScope.INDIVIDUAL_SCOPE_IN_TEAM));
export const useGameCards = ({
currentEpoch,
onlyActive,
}: {
currentEpoch: number;
onlyActive: boolean;
}): { data: EnrichedTransfer[]; loading: boolean; error?: ApolloError } => {
const { data, loading, error } = useActiveRewardsQuery({
variables: {
isReward: true,
},
fetchPolicy: 'cache-and-network',
});
const { data: assets, loading: assetsLoading } = useAssetsMapProvider();
const { data: markets, loading: marketsLoading } = useMarketsMapProvider();
const games = compact(data?.transfersConnection?.edges?.map((n) => n?.node))
.map((n) => n as TransferNode)
.filter((node) => {
const active = onlyActive ? isActiveReward(node, currentEpoch) : true;
return active && isScopedToTeams(node);
})
.map((node) => {
if (node.transfer.kind.__typename !== 'RecurringTransfer') {
return node;
}
const asset =
assets &&
assets[
node.transfer.kind.dispatchStrategy?.dispatchMetricAssetId || ''
];
const marketsInScope =
node.transfer.kind.dispatchStrategy?.marketIdsInScope?.map(
(id) => markets && markets[id]
);
return { ...node, asset, markets: marketsInScope };
});
return {
data: games,
loading: loading || assetsLoading || marketsLoading,
error,
};
};

View File

@ -0,0 +1,242 @@
import {
type DispatchStrategy,
type TransferNode,
EntityScope,
type TransferKind,
TransferStatus,
IndividualScope,
} from '@vegaprotocol/types';
import {
type RewardTransfer,
isActiveReward,
isReward,
isScopedToTeams,
} from './use-rewards';
const makeDispatchStrategy = (
entityScope: EntityScope,
individualScope?: IndividualScope
): DispatchStrategy =>
({
entityScope,
individualScope,
} as DispatchStrategy);
const makeReward = (
status: TransferStatus,
startEpoch: number,
endEpoch?: number,
dispatchStrategy?: DispatchStrategy,
kind: TransferKind['__typename'] = 'OneOffTransfer'
): RewardTransfer =>
({
transfer: {
status,
kind: {
__typename: kind,
dispatchStrategy,
startEpoch,
endEpoch,
},
},
} as RewardTransfer);
describe('isReward', () => {
it.each([
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'RecurringTransfer'
),
true,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_INDIVIDUALS),
'RecurringTransfer'
),
true,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
undefined,
'RecurringTransfer'
),
false,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'OneOffTransfer'
),
false,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_INDIVIDUALS),
'OneOffTransfer'
),
false,
],
])('checks if given transfer is a reward or not', (input, output) => {
expect(isReward(input as TransferNode)).toEqual(output);
});
});
describe('isActiveReward', () => {
it.each([
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'RecurringTransfer'
),
true,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
2,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'RecurringTransfer'
),
true,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
3,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'RecurringTransfer'
),
true,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
4, // start in 1 epoch
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'RecurringTransfer'
),
false,
],
[
makeReward(
TransferStatus.STATUS_DONE, // done, not active any more
1,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'RecurringTransfer'
),
false,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
2, // ended 1 epoch ago
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'RecurringTransfer'
),
false,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
3, // ends now, but active until end of epoch
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS),
'RecurringTransfer'
),
true,
],
])('checks if given reward is active or not', (input, output) => {
expect(isActiveReward(input, 3)).toEqual(output);
});
});
describe('isScopedToTeams', () => {
it.each([
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_TEAMS), // only teams
'RecurringTransfer'
),
true,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(
EntityScope.ENTITY_SCOPE_INDIVIDUALS,
IndividualScope.INDIVIDUAL_SCOPE_IN_TEAM // individual in teams
),
'RecurringTransfer'
),
true,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(EntityScope.ENTITY_SCOPE_INDIVIDUALS), // not in team
'RecurringTransfer'
),
false,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(
EntityScope.ENTITY_SCOPE_INDIVIDUALS,
IndividualScope.INDIVIDUAL_SCOPE_ALL // not only in team
),
'RecurringTransfer'
),
false,
],
[
makeReward(
TransferStatus.STATUS_PENDING,
1,
undefined,
makeDispatchStrategy(
EntityScope.ENTITY_SCOPE_INDIVIDUALS,
IndividualScope.INDIVIDUAL_SCOPE_NOT_IN_TEAM // not in team
),
'RecurringTransfer'
),
false,
],
])('checks if given reward is scoped to teams or not', (input, output) => {
expect(isScopedToTeams(input)).toEqual(output);
});
});

View File

@ -0,0 +1,181 @@
import {
type AssetFieldsFragment,
useAssetsMapProvider,
} from '@vegaprotocol/assets';
import { useActiveRewardsQuery } from './__generated__/Rewards';
import {
type MarketFieldsFragment,
useMarketsMapProvider,
getAsset,
} from '@vegaprotocol/markets';
import {
type RecurringTransfer,
type TransferNode,
TransferStatus,
type DispatchStrategy,
EntityScope,
IndividualScope,
MarketState,
} from '@vegaprotocol/types';
import { type ApolloError } from '@apollo/client';
import compact from 'lodash/compact';
import { useEpochInfoQuery } from './__generated__/Epoch';
export type RewardTransfer = TransferNode & {
transfer: {
kind: RecurringTransfer & {
dispatchStrategy: DispatchStrategy;
};
};
};
export type EnrichedRewardTransfer = RewardTransfer & {
/** Dispatch metric asset (reward asset) */
asset?: AssetFieldsFragment;
/** A flag determining whether a reward asset is being traded on any of the active markets */
isAssetTraded?: boolean;
/** A list of markets in scope */
markets?: MarketFieldsFragment[];
};
/**
* Checks if given transfer is a reward.
*
* A reward has to be a recurring transfer and has to have a
* dispatch strategy.
*/
export const isReward = (node: TransferNode): node is RewardTransfer => {
if (
node.transfer.kind.__typename === 'RecurringTransfer' &&
node.transfer.kind.dispatchStrategy != null
) {
return true;
}
return false;
};
/**
* Checks if given reward (transfer) is active.
*/
export const isActiveReward = (node: RewardTransfer, currentEpoch: number) => {
const { transfer } = node;
const pending = transfer.status === TransferStatus.STATUS_PENDING;
const withinEpochs =
transfer.kind.startEpoch <= currentEpoch &&
(transfer.kind.endEpoch != null
? transfer.kind.endEpoch >= currentEpoch
: true);
if (pending && withinEpochs) return true;
return false;
};
/**
* Checks if given reward (transfer) is scoped to teams.
*
* A reward is scoped to teams if it's entity scope is set to teams or
* if the scope is set to individuals but the individuals are in a team.
*/
export const isScopedToTeams = (node: EnrichedRewardTransfer) =>
// scoped to teams
node.transfer.kind.dispatchStrategy.entityScope ===
EntityScope.ENTITY_SCOPE_TEAMS ||
// or to individuals
(node.transfer.kind.dispatchStrategy.entityScope ===
EntityScope.ENTITY_SCOPE_INDIVIDUALS &&
// but they have to be in a team
node.transfer.kind.dispatchStrategy.individualScope ===
IndividualScope.INDIVIDUAL_SCOPE_IN_TEAM);
/** Retrieves rewards (transfers) */
export const useRewards = ({
// get active by default
onlyActive = true,
scopeToTeams = false,
}: {
onlyActive: boolean;
scopeToTeams?: boolean;
}): {
data: EnrichedRewardTransfer[];
loading: boolean;
error?: ApolloError | Error;
} => {
const {
data: epochData,
loading: epochLoading,
error: epochError,
} = useEpochInfoQuery({
fetchPolicy: 'network-only',
});
const currentEpoch = Number(epochData?.epoch.id);
const { data, loading, error } = useActiveRewardsQuery({
variables: {
isReward: true,
},
skip: onlyActive && isNaN(currentEpoch),
fetchPolicy: 'cache-and-network',
});
const {
data: assets,
loading: assetsLoading,
error: assetsError,
} = useAssetsMapProvider();
const {
data: markets,
loading: marketsLoading,
error: marketsError,
} = useMarketsMapProvider();
const enriched = compact(
data?.transfersConnection?.edges?.map((n) => n?.node)
)
.map((n) => n as TransferNode)
// make sure we have only rewards here
.filter(isReward)
// take only active rewards if required, otherwise take all
.filter((node) => (onlyActive ? isActiveReward(node, currentEpoch) : true))
// take only those rewards that are scoped to teams if required, otherwise take all
.filter((node) => (scopeToTeams ? isScopedToTeams(node) : true))
// enrich with dispatch asset and markets in scope details
.map((node) => {
const asset =
assets &&
assets[node.transfer.kind.dispatchStrategy.dispatchMetricAssetId];
const marketsInScope = compact(
node.transfer.kind.dispatchStrategy.marketIdsInScope?.map(
(id) => markets && markets[id]
)
);
const isAssetTraded =
markets &&
Object.values(markets).some((m) => {
try {
const mAsset = getAsset(m);
return (
mAsset.id ===
node.transfer.kind.dispatchStrategy.dispatchMetricAssetId &&
m.state === MarketState.STATE_ACTIVE
);
} catch {
// NOOP
}
return false;
});
return {
...node,
asset: asset ? asset : undefined,
isAssetTraded: isAssetTraded != null ? isAssetTraded : undefined,
markets: marketsInScope.length > 0 ? marketsInScope : undefined,
};
});
return {
data: enriched,
loading: loading || assetsLoading || marketsLoading || epochLoading,
error: error || assetsError || marketsError || epochError,
};
};

View File

@ -93,16 +93,23 @@ export const useEnabledAssets = () => {
/** Wrapped ETH symbol */ /** Wrapped ETH symbol */
const WETH = 'WETH'; const WETH = 'WETH';
type WETHDetails = Pick<AssetFieldsFragment, 'symbol' | 'decimals' | 'quantum'>;
/** VEGA */
const VEGA = 'VEGA';
export type BasicAssetDetails = Pick<
AssetFieldsFragment,
'symbol' | 'decimals' | 'quantum'
>;
/** /**
* Tries to find WETH asset configuration on Vega in order to provide its * Tries to find WETH asset configuration on Vega in order to provide its
* details, otherwise it returns hardcoded values. * details, otherwise it returns hardcoded values.
*/ */
export const useWETH = (): WETHDetails => { export const useWETH = (): BasicAssetDetails => {
const { data } = useAssetsDataProvider(); const { data } = useAssetsDataProvider();
if (data) { if (data) {
const weth = data.find((a) => a.symbol.toUpperCase() === WETH); const details = data.find((a) => a.symbol.toUpperCase() === WETH);
if (weth) return weth; if (details) return details;
} }
return { return {
@ -111,3 +118,17 @@ export const useWETH = (): WETHDetails => {
quantum: '500000000000000', // 1 WETH ~= 2000 qUSD quantum: '500000000000000', // 1 WETH ~= 2000 qUSD
}; };
}; };
export const useVEGA = (): BasicAssetDetails => {
const { data } = useAssetsDataProvider();
if (data) {
const details = data.find((a) => a.symbol.toUpperCase() === VEGA);
if (details) return details;
}
return {
symbol: VEGA,
decimals: 18,
quantum: '1000000000000000000', // 1 VEGA ~= 1 qUSD
};
};