623 lines
18 KiB
TypeScript
623 lines
18 KiB
TypeScript
import { useT } from '../../lib/use-t';
|
|
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
|
|
import classNames from 'classnames';
|
|
import {
|
|
type VegaIconSize,
|
|
Tooltip,
|
|
VegaIcon,
|
|
VegaIconNames,
|
|
TradingInput,
|
|
TinyScroll,
|
|
truncateMiddle,
|
|
} from '@vegaprotocol/ui-toolkit';
|
|
import {
|
|
type TransferNode,
|
|
DistributionStrategyDescriptionMapping,
|
|
DistributionStrategyMapping,
|
|
EntityScope,
|
|
EntityScopeMapping,
|
|
DispatchMetric,
|
|
DispatchMetricDescription,
|
|
DispatchMetricLabels,
|
|
EntityScopeLabelMapping,
|
|
MarketState,
|
|
type DispatchStrategy,
|
|
IndividualScopeMapping,
|
|
IndividualScopeDescriptionMapping,
|
|
} from '@vegaprotocol/types';
|
|
import { Card } from '../card/card';
|
|
import { type ReactNode, useState } from 'react';
|
|
import {
|
|
type AssetFieldsFragment,
|
|
type BasicAssetDetails,
|
|
} from '@vegaprotocol/assets';
|
|
import { type MarketFieldsFragment } from '@vegaprotocol/markets';
|
|
import {
|
|
type EnrichedRewardTransfer,
|
|
useRewards,
|
|
} from '../../lib/hooks/use-rewards';
|
|
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 = {
|
|
searchTerm: string;
|
|
};
|
|
|
|
export const applyFilter = (
|
|
node: TransferNode & {
|
|
asset?: AssetFieldsFragment | null;
|
|
markets?: (MarketFieldsFragment | null)[];
|
|
},
|
|
filter: Filter
|
|
) => {
|
|
const { transfer } = node;
|
|
if (
|
|
transfer.kind.__typename !== 'RecurringTransfer' ||
|
|
!transfer.kind.dispatchStrategy?.dispatchMetric
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
DispatchMetricLabels[transfer.kind.dispatchStrategy.dispatchMetric]
|
|
.toLowerCase()
|
|
.includes(filter.searchTerm.toLowerCase()) ||
|
|
transfer.asset?.symbol
|
|
.toLowerCase()
|
|
.includes(filter.searchTerm.toLowerCase()) ||
|
|
(
|
|
EntityScopeLabelMapping[transfer.kind.dispatchStrategy.entityScope] ||
|
|
'Unspecified'
|
|
)
|
|
.toLowerCase()
|
|
.includes(filter.searchTerm.toLowerCase()) ||
|
|
node.asset?.name
|
|
.toLocaleLowerCase()
|
|
.includes(filter.searchTerm.toLowerCase()) ||
|
|
node.markets?.some((m) =>
|
|
m?.tradableInstrument?.instrument?.name
|
|
.toLocaleLowerCase()
|
|
.includes(filter.searchTerm.toLowerCase())
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
export const ActiveRewards = ({ currentEpoch }: { currentEpoch: number }) => {
|
|
const t = useT();
|
|
const { data } = useRewards({
|
|
onlyActive: true,
|
|
});
|
|
|
|
const [filter, setFilter] = useState<Filter>({
|
|
searchTerm: '',
|
|
});
|
|
|
|
if (!data || !data.length) return null;
|
|
|
|
return (
|
|
<Card
|
|
title={t('Active rewards')}
|
|
className="lg:col-span-full"
|
|
data-testid="active-rewards-card"
|
|
>
|
|
{/** CARDS FILTER */}
|
|
{data.length > 1 && (
|
|
<TradingInput
|
|
onChange={(e) =>
|
|
setFilter((curr) => ({ ...curr, searchTerm: e.target.value }))
|
|
}
|
|
value={filter.searchTerm}
|
|
type="text"
|
|
placeholder={t(
|
|
'Search by reward dispatch metric, entity scope or asset name'
|
|
)}
|
|
data-testid="search-term"
|
|
className="mb-4 w-20 mr-2"
|
|
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">
|
|
{data
|
|
.filter((n) => applyFilter(n, filter))
|
|
.map((node, i) => (
|
|
<ActiveRewardCard
|
|
key={i}
|
|
transferNode={node}
|
|
currentEpoch={currentEpoch}
|
|
/>
|
|
))}
|
|
</TinyScroll>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
type ActiveRewardCardProps = {
|
|
transferNode: EnrichedRewardTransfer;
|
|
currentEpoch: number;
|
|
};
|
|
export const ActiveRewardCard = ({
|
|
transferNode,
|
|
currentEpoch,
|
|
}: ActiveRewardCardProps) => {
|
|
// don't display the cards that are scoped to not trading markets
|
|
const marketSettled = transferNode.markets?.filter(
|
|
(m) =>
|
|
m?.state &&
|
|
[
|
|
MarketState.STATE_TRADING_TERMINATED,
|
|
MarketState.STATE_SETTLED,
|
|
MarketState.STATE_CANCELLED,
|
|
MarketState.STATE_CLOSED,
|
|
].includes(m.state)
|
|
);
|
|
|
|
// 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;
|
|
}
|
|
|
|
let colour =
|
|
DispatchMetricColourMap[
|
|
transferNode.transfer.kind.dispatchStrategy.dispatchMetric
|
|
];
|
|
|
|
// 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?.state === MarketState.STATE_SUSPENDED ||
|
|
m?.state === MarketState.STATE_SUSPENDED_VIA_GOVERNANCE
|
|
).length === transferNode.markets?.length &&
|
|
Boolean(transferNode.markets && transferNode.markets.length > 0);
|
|
|
|
if (marketSuspended || !transferNode.isAssetTraded) {
|
|
colour = CardColour.GREY;
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
<div
|
|
className={classNames(
|
|
'bg-gradient-to-r col-span-full p-0.5 lg:col-auto h-full',
|
|
'rounded-lg',
|
|
CardColourStyles[colour].gradientClassName
|
|
)}
|
|
data-testid="active-rewards-card"
|
|
>
|
|
<div
|
|
className={classNames(
|
|
CardColourStyles[colour].mainClassName,
|
|
'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">
|
|
{/** ENTITY SCOPE */}
|
|
<div className="flex flex-col gap-2 items-center text-center">
|
|
<EntityIcon entityScope={dispatchStrategy.entityScope} />
|
|
{dispatchStrategy.entityScope && (
|
|
<span className="text-muted text-xs" data-testid="entity-scope">
|
|
{EntityScopeLabelMapping[dispatchStrategy.entityScope] ||
|
|
t('Unspecified')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/** AMOUNT AND DISTRIBUTION STRATEGY */}
|
|
<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">
|
|
<span className="font-glitch" data-testid="reward-value">
|
|
{rewardAmount}
|
|
</span>
|
|
|
|
<span className="font-alpha">{rewardAsset?.symbol || ''}</span>
|
|
</h3>
|
|
|
|
{/** DISTRIBUTION STRATEGY */}
|
|
<Tooltip
|
|
description={t(
|
|
DistributionStrategyDescriptionMapping[
|
|
dispatchStrategy.distributionStrategy
|
|
]
|
|
)}
|
|
underline={true}
|
|
>
|
|
<span className="text-xs" data-testid="distribution-strategy">
|
|
{
|
|
DistributionStrategyMapping[
|
|
dispatchStrategy.distributionStrategy
|
|
]
|
|
}
|
|
</span>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/** DISTRIBUTION DELAY */}
|
|
<div className="flex flex-col gap-2 items-center text-center">
|
|
<CardIcon
|
|
iconName={VegaIconNames.LOCK}
|
|
tooltip={t(
|
|
'Number of epochs after distribution to delay vesting of rewards by'
|
|
)}
|
|
/>
|
|
<span
|
|
className="text-muted text-xs whitespace-nowrap"
|
|
data-testid="locked-for"
|
|
>
|
|
{t('numberEpochs', '{{count}} epochs', {
|
|
count: dispatchStrategy.lockPeriod,
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<span className="border-[0.5px] border-gray-700" />
|
|
{/** DISPATCH METRIC */}
|
|
{dispatchMetricInfo ? (
|
|
dispatchMetricInfo
|
|
) : (
|
|
<span data-testid="dispatch-metric-info">
|
|
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]}
|
|
</span>
|
|
)}
|
|
|
|
<div className="flex items-center gap-8 flex-wrap">
|
|
{/** ENDS IN */}
|
|
{endsIn != null && (
|
|
<span className="flex flex-col">
|
|
<span className="text-muted text-xs">{t('Ends in')} </span>
|
|
<span data-testid="ends-in" data-endsin={endsIn}>
|
|
{endsIn >= 0
|
|
? t('numberEpochs', '{{count}} epochs', {
|
|
count: endsIn,
|
|
})
|
|
: t('Ended')}
|
|
</span>
|
|
</span>
|
|
)}
|
|
|
|
{/** WINDOW LENGTH */}
|
|
<span className="flex flex-col">
|
|
<span className="text-muted text-xs">{t('Assessed over')}</span>
|
|
<span data-testid="assessed-over">
|
|
{t('numberEpochs', '{{count}} epochs', {
|
|
count: dispatchStrategy.windowLength,
|
|
})}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{/** DISPATCH METRIC DESCRIPTION */}
|
|
{dispatchStrategy?.dispatchMetric && (
|
|
<span className="text-muted text-sm h-[3rem]">
|
|
{t(DispatchMetricDescription[dispatchStrategy?.dispatchMetric])}
|
|
</span>
|
|
)}
|
|
<span className="border-[0.5px] border-gray-700" />
|
|
{/** REQUIREMENTS */}
|
|
{dispatchStrategy && (
|
|
<RewardRequirements
|
|
dispatchStrategy={dispatchStrategy}
|
|
rewardAsset={rewardAsset}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const DispatchMetricInfo = ({
|
|
reward,
|
|
}: {
|
|
reward: EnrichedRewardTransfer;
|
|
}) => {
|
|
const t = useT();
|
|
const dispatchStrategy = reward.transfer.kind.dispatchStrategy;
|
|
const marketNames = compact(
|
|
reward.markets?.map((m) => m.tradableInstrument.instrument.name)
|
|
);
|
|
|
|
let additionalDispatchMetricInfo = null;
|
|
|
|
// if asset found then display asset symbol
|
|
if (reward.asset) {
|
|
additionalDispatchMetricInfo = <span>{reward.asset.symbol}</span>;
|
|
}
|
|
// but if scoped to only one market then display market name
|
|
if (marketNames.length === 1) {
|
|
additionalDispatchMetricInfo = <span>{marketNames[0]}</span>;
|
|
}
|
|
// or if scoped to many markets then indicate it's scoped to "specific markets"
|
|
if (marketNames.length > 1) {
|
|
additionalDispatchMetricInfo = (
|
|
<Tooltip description={marketNames}>
|
|
<span>{t('Specific markets')}</span>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span data-testid="dispatch-metric-info">
|
|
{DispatchMetricLabels[dispatchStrategy.dispatchMetric]}
|
|
{additionalDispatchMetricInfo != null && (
|
|
<> • {additionalDispatchMetricInfo}</>
|
|
)}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const RewardRequirements = ({
|
|
dispatchStrategy,
|
|
rewardAsset,
|
|
vegaAsset,
|
|
}: {
|
|
dispatchStrategy: DispatchStrategy;
|
|
rewardAsset?: BasicAssetDetails;
|
|
vegaAsset?: BasicAssetDetails;
|
|
}) => {
|
|
const t = useT();
|
|
|
|
const entityLabel = EntityScopeLabelMapping[dispatchStrategy.entityScope];
|
|
|
|
return (
|
|
<dl className="flex justify-between flex-wrap items-center gap-3 text-xs">
|
|
<div className="flex flex-col gap-1">
|
|
<dt className="flex items-center gap-1 text-muted">
|
|
{entityLabel
|
|
? t('{{entity}} scope', {
|
|
entity: entityLabel,
|
|
})
|
|
: t('Scope')}
|
|
</dt>
|
|
<dd className="flex items-center gap-1" data-testid="scope">
|
|
<RewardEntityScope dispatchStrategy={dispatchStrategy} />
|
|
</dd>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<dt className="flex items-center gap-1 text-muted">
|
|
{t('Staked VEGA')}
|
|
</dt>
|
|
<dd
|
|
className="flex items-center gap-1"
|
|
data-testid="staking-requirement"
|
|
>
|
|
{addDecimalsFormatNumber(
|
|
dispatchStrategy?.stakingRequirement || 0,
|
|
vegaAsset?.decimals || 18
|
|
)}
|
|
</dd>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<dt className="flex items-center gap-1 text-muted">
|
|
{t('Average position')}
|
|
</dt>
|
|
<dd className="flex items-center gap-1" data-testid="average-position">
|
|
{addDecimalsFormatNumber(
|
|
dispatchStrategy?.notionalTimeWeightedAveragePositionRequirement ||
|
|
0,
|
|
rewardAsset?.decimals || 0
|
|
)}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
);
|
|
};
|
|
|
|
const RewardEntityScope = ({
|
|
dispatchStrategy,
|
|
}: {
|
|
dispatchStrategy: DispatchStrategy;
|
|
}) => {
|
|
const t = useT();
|
|
|
|
if (dispatchStrategy.entityScope === EntityScope.ENTITY_SCOPE_TEAMS) {
|
|
return (
|
|
<Tooltip
|
|
description={
|
|
dispatchStrategy.teamScope?.length ? (
|
|
<div className="text-xs">
|
|
<p className="mb-1">{t('Eligible teams')}</p>
|
|
<ul>
|
|
{dispatchStrategy.teamScope.map((teamId) => {
|
|
if (!teamId) return null;
|
|
return <li key={teamId}>{truncateMiddle(teamId)}</li>;
|
|
})}
|
|
</ul>
|
|
</div>
|
|
) : (
|
|
t('All teams are eligible')
|
|
)
|
|
}
|
|
>
|
|
<span>
|
|
{dispatchStrategy.teamScope?.length
|
|
? t('Some teams')
|
|
: t('All teams')}
|
|
</span>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
if (
|
|
dispatchStrategy.entityScope === EntityScope.ENTITY_SCOPE_INDIVIDUALS &&
|
|
dispatchStrategy.individualScope
|
|
) {
|
|
return (
|
|
<Tooltip
|
|
description={
|
|
IndividualScopeDescriptionMapping[dispatchStrategy.individualScope]
|
|
}
|
|
>
|
|
<span>{IndividualScopeMapping[dispatchStrategy.individualScope]}</span>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return t('Unspecified');
|
|
};
|
|
|
|
const CardColourStyles: Record<
|
|
CardColour,
|
|
{ gradientClassName: string; mainClassName: string }
|
|
> = {
|
|
[CardColour.BLUE]: {
|
|
gradientClassName: 'from-vega-blue-500 to-vega-green-400',
|
|
mainClassName: 'from-vega-blue-400 dark:from-vega-blue-600 to-20%',
|
|
},
|
|
[CardColour.GREEN]: {
|
|
gradientClassName: 'from-vega-green-500 to-vega-yellow-500',
|
|
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',
|
|
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 = ({
|
|
size = 18,
|
|
iconName,
|
|
tooltip,
|
|
}: {
|
|
size?: VegaIconSize;
|
|
iconName: VegaIconNames;
|
|
tooltip: string;
|
|
}) => {
|
|
return (
|
|
<Tooltip description={<span>{tooltip}</span>}>
|
|
<span className="flex items-center p-2 rounded-full border border-gray-600">
|
|
<VegaIcon name={iconName} size={size} />
|
|
</span>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
const EntityScopeIconMap: Record<EntityScope, VegaIconNames> = {
|
|
[EntityScope.ENTITY_SCOPE_TEAMS]: VegaIconNames.TEAM,
|
|
[EntityScope.ENTITY_SCOPE_INDIVIDUALS]: VegaIconNames.MAN,
|
|
};
|
|
|
|
const EntityIcon = ({
|
|
entityScope,
|
|
size = 18,
|
|
}: {
|
|
entityScope: EntityScope;
|
|
size?: VegaIconSize;
|
|
}) => {
|
|
return (
|
|
<Tooltip
|
|
description={
|
|
entityScope ? <span>{EntityScopeMapping[entityScope]}</span> : undefined
|
|
}
|
|
>
|
|
<span className="flex items-center p-2 rounded-full border border-gray-600">
|
|
<VegaIcon
|
|
name={EntityScopeIconMap[entityScope] || VegaIconNames.QUESTION_MARK}
|
|
size={size}
|
|
/>
|
|
</span>
|
|
</Tooltip>
|
|
);
|
|
};
|