Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/staking/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,14 @@
"i18next": "^22.5.1",
"immer": "^10.0.2",
"lodash": "4.17.21",
"rambda": "^8.5.0",
"react-copy-to-clipboard": "^5.1.0",
"react-i18next": "^12.3.1",
"recharts": "^2.9.2",
"zustand": "^4.4.1"
},
"devDependencies": {
"@cardano-sdk/core": "0.21.0",
"@cardano-sdk/input-selection": "0.12.4",
"@cardano-sdk/tx-construction": "0.14.2",
"@cardano-sdk/util": "0.14.2",
Expand Down
12 changes: 8 additions & 4 deletions packages/staking/src/features/activity/Activity.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { StateStatus, useOutsideHandles } from 'features/outside-handles-provider';
import { getGroupedRewardsActivities } from './helpers/getGroupedRewardsHistory';
import { NoStakingActivity } from './NoStakingActivity';
import { PastEpochsRewards } from './PastEpochsRewards';
import { RewardsHistory } from './RewardsHistory';

export const Activity = () => {
Expand All @@ -13,10 +14,13 @@ export const Activity = () => {
{walletActivitiesStatus === StateStatus.LOADED && groupedRewardsActivities.length === 0 ? (
<NoStakingActivity />
) : (
<RewardsHistory
walletActivitiesStatus={walletActivitiesStatus}
groupedRewardsActivities={groupedRewardsActivities}
/>
<>
<PastEpochsRewards />
<RewardsHistory
walletActivitiesStatus={walletActivitiesStatus}
groupedRewardsActivities={groupedRewardsActivities}
/>
</>
)}
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.buttonsBackground {
background-color: var(--light-mode-light-grey, var(--dark-mode-dark-grey, #2f2f2f));
border-radius: 1rem;
}
33 changes: 33 additions & 0 deletions packages/staking/src/features/activity/EpochsSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ControlButton, Flex, Text } from '@lace/ui';
import { useTranslation } from 'react-i18next';
import styles from './EpochsSwitch.module.scss';

// eslint-disable-next-line no-magic-numbers
const EPOCHS_OPTIONS = [5, 15];

type EpochsSwitchProps = {
epochsCount: number;
setEpochsCount: (epochsCount: number) => void;
};

export const EpochsSwitch = ({ epochsCount, setEpochsCount }: EpochsSwitchProps) => {
const { t } = useTranslation();
return (
<Flex gap="$8" alignItems="center">
<Text.Body.Normal>{t('activity.rewardsChart.epochs')}:</Text.Body.Normal>
<Flex p="$8" gap="$8" alignItems="center" className={styles.buttonsBackground}>
{EPOCHS_OPTIONS.map((option, i) => {
const activeOption = epochsCount === option;
const Component = activeOption ? ControlButton.Filled : ControlButton.Outlined;
return (
<Component
key={i}
label={`${t('activity.rewardsChart.last')} ${option}`}
onClick={() => setEpochsCount(option)}
/>
);
})}
</Flex>
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Flex, Text } from '@lace/ui';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EpochsSwitch } from '../EpochsSwitch';
import { useRewardsByEpoch } from './hooks/useRewardsByEpoch';
import { RewardsChart } from './RewardsChart';

const DEFAULT_LAST_EPOCHS = 5;

export const PastEpochsRewards = () => {
const [epochsCount, setEpochsCount] = useState(DEFAULT_LAST_EPOCHS);
const { t } = useTranslation();
const { rewardsByEpoch } = useRewardsByEpoch({ epochsCount });
return (
<>
<Flex mb="$32" justifyContent="space-between" alignItems="center">
<Text.SubHeading>{t('activity.rewardsChart.title')}</Text.SubHeading>
<EpochsSwitch epochsCount={epochsCount} setEpochsCount={setEpochsCount} />
</Flex>
{rewardsByEpoch && <RewardsChart chartData={rewardsByEpoch} />}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PIE_CHART_DEFAULT_COLOR_SET } from '@lace/ui';

export const PoolIndicator = ({ color = PIE_CHART_DEFAULT_COLOR_SET[0] }: { color?: string }) => (
<svg width="4" height="41" viewBox="0 0 4 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="0.5" width="40" height="4" rx="2" transform="rotate(90 4 0.5)" fill={color} />
</svg>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Card } from '@lace/ui';
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { RewardsByEpoch } from './hooks/useRewardsByEpoch';
import { useRewardsChartPoolsColorMapper } from './hooks/useRewardsChartPoolsColorMapper';
import { RewardsChartTooltip } from './RewardsChartTooltip';

export const RewardsChart = ({ chartData }: { chartData: RewardsByEpoch }) => {
const poolColorMapper = useRewardsChartPoolsColorMapper(chartData);
// eslint-disable-next-line unicorn/no-array-reduce
const maxPoolsPerEpochCount = chartData.reduce((acc, epochRewards) => Math.max(acc, epochRewards.rewards.length), 0);

return (
<Card.Outlined>
<ResponsiveContainer width="100%" aspect={2.4} height="auto">
<BarChart
width={500}
height={300}
data={chartData}
margin={{
bottom: 32,
left: 24,
right: 24,
top: 32,
}}
>
<XAxis dataKey="spendableEpoch" tickLine={false} axisLine={false} tickMargin={16} />
<YAxis tickLine={false} axisLine={false} tickFormatter={(value) => `${value} ADA`} />
<Tooltip cursor={false} content={<RewardsChartTooltip poolColorMapper={poolColorMapper} />} />
{Array.from({ length: maxPoolsPerEpochCount }).map((_, i) => (
<Bar key={i} dataKey={`rewards[${i}].rewards`} stackId="a" maxBarSize={24} isAnimationActive={false}>
{chartData.map((entry, j) => {
const fill = poolColorMapper(entry.rewards[i]?.poolId);
return <Cell key={`cell-${j}`} fill={fill} />;
})}
</Bar>
))}
</BarChart>
</ResponsiveContainer>
</Card.Outlined>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Card, Flex, Text } from '@lace/ui';
import { useTranslation } from 'react-i18next';
import { TooltipProps } from 'recharts';
import { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
import { useRewardsChartPoolsColorMapper } from './hooks/useRewardsChartPoolsColorMapper';
import { PoolIndicator } from './PoolIndicator';

export const RewardsChartTooltip = ({
active,
payload,
label,
poolColorMapper,
}: TooltipProps<ValueType, NameType> & { poolColorMapper: ReturnType<typeof useRewardsChartPoolsColorMapper> }) => {
const { t } = useTranslation();

if (active && payload && payload.length > 0) {
return (
<Card.Elevated className="custom-tooltip">
<Flex flexDirection="column" px="$16" py="$8">
<Text.Body.Small weight="$semibold">
{t('activity.rewardsChart.epoch')} {label}
</Text.Body.Small>
<Flex flexDirection="column" gap="$4">
{payload.map((p, i) => {
const poolId = p.payload?.rewards?.[i]?.poolId;
return (
<Flex gap="$8" key={i} alignItems="center">
<PoolIndicator color={poolColorMapper(poolId)} />
<Flex flexDirection="column">
<Text.Body.Small>{p.payload?.rewards?.[i]?.metadata.name}</Text.Body.Small>
<Text.Body.Small>
{t('activity.rewardsChart.rewards')}: {payload[i]?.value} ADA
</Text.Body.Small>
</Flex>
</Flex>
);
})}
</Flex>
</Flex>
</Card.Elevated>
);
}

return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Cardano, Reward } from '@cardano-sdk/core';
import { RewardsHistory } from '@cardano-sdk/wallet';
import { Wallet } from '@lace/cardano';
import { useObservable } from '@lace/common';
import { useOutsideHandles } from 'features/outside-handles-provider';
import { groupBy, sortBy, takeLast, uniqBy } from 'rambda';
import { useEffect, useState } from 'react';

type RewardWithPoolMetadata = Omit<Reward, 'rewards' | 'epoch'> & {
metadata: Cardano.StakePoolMetadata | undefined;
spendableEpoch: Cardano.EpochNo;
rewards: string;
};

export type RewardsByEpoch = { spendableEpoch: Cardano.EpochNo; rewards: RewardWithPoolMetadata[] }[];

export type UseRewardsByEpochProps = {
epochsCount: number;
};

const getPoolInfos = async (poolIds: Wallet.Cardano.PoolId[], stakePoolProvider: Wallet.StakePoolProvider) => {
const filters: Wallet.QueryStakePoolsArgs = {
filters: {
identifier: {
_condition: 'or',
values: poolIds.map((poolId) => ({ id: poolId })),
},
},
pagination: {
limit: 100,
startAt: 0,
},
};
const { pageResults: pools } = await stakePoolProvider.queryStakePools(filters);

return pools;
};

type GetRewardsByEpochProps = {
rewardsHistory: RewardsHistory;
stakePoolProvider: Wallet.StakePoolProvider;
epochsCount: number;
};

const buildRewardsByEpoch = async ({ rewardsHistory, stakePoolProvider, epochsCount }: GetRewardsByEpochProps) => {
const REWARD_SPENDABLE_DELAY_EPOCHS = 2;
const uniqPoolIds = uniqBy((rewards) => rewards.poolId, rewardsHistory.all)
.map((reward) => reward.poolId)
.filter(Boolean) as Wallet.Cardano.PoolId[];
const stakePoolsData = await getPoolInfos(uniqPoolIds, stakePoolProvider);
const rewardsHistoryWithMetadata = rewardsHistory.all.map((reward) => ({
...reward,
metadata: stakePoolsData.find((poolInfo) => poolInfo.id === reward.poolId)?.metadata,
rewards: Wallet.util.lovelacesToAdaString(reward.rewards.toString()),
spendableEpoch: (reward.epoch + REWARD_SPENDABLE_DELAY_EPOCHS) as Cardano.EpochNo,
}));
const groupedRewards = groupBy(({ epoch }) => epoch.toString(), rewardsHistoryWithMetadata);
const groupedRewardsArray = Object.entries(groupedRewards).map(([epoch, rewards]) => ({
rewards,
spendableEpoch: (Number.parseInt(epoch) + REWARD_SPENDABLE_DELAY_EPOCHS) as Cardano.EpochNo,
}));
const sortedByEpoch = sortBy((entry) => entry.spendableEpoch, groupedRewardsArray);
return takeLast(epochsCount, sortedByEpoch);
};

export const useRewardsByEpoch = ({ epochsCount }: UseRewardsByEpochProps) => {
const [rewardsByEpoch, setRewardsByEpoch] = useState<RewardsByEpoch>();
const { walletStoreInMemoryWallet: inMemoryWallet, walletStoreBlockchainProvider } = useOutsideHandles();
const rewardsHistory = useObservable(inMemoryWallet.delegation.rewardsHistory$);
useEffect(() => {
if (!rewardsHistory) return;
(async () => {
const result = await buildRewardsByEpoch({
epochsCount,
rewardsHistory,
stakePoolProvider: walletStoreBlockchainProvider.stakePoolProvider,
});
setRewardsByEpoch(result);
})();
}, [epochsCount, rewardsHistory, walletStoreBlockchainProvider]);
return { rewardsByEpoch };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* eslint-disable unicorn/no-array-reduce */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { PIE_CHART_DEFAULT_COLOR_SET } from '@lace/ui';
import { useDelegationPortfolioStore } from 'features/store';
import difference from 'lodash/difference';
import { useMemo } from 'react';
import type { RewardsByEpoch } from './useRewardsByEpoch';
import type { Cardano } from '@cardano-sdk/core';

const GRAYSCALE_PALETTE = [
'#343434',
'#4a4a4a',
'#616161',
'#787878',
'#8e8e8e',
'#a5a5a5',
'#bbbbbb',
'#d2d2d2',
'#e8e8e8',
'#fafafa',
];

export const useRewardsChartPoolsColorMapper = (rewardsByEpoch: RewardsByEpoch) => {
const { currentPortfolio } = useDelegationPortfolioStore();

const coloring = useMemo(() => {
const poolsInPortfolio = currentPortfolio.map(({ stakePool }) => stakePool.id);
const historicalPools = rewardsByEpoch
.flatMap((rewards) => rewards.rewards.map((reward) => reward.poolId))
.filter(Boolean) as Cardano.PoolId[];
const poolsNotInPortfolio = difference(historicalPools, poolsInPortfolio);

const poolsInPortfolioColoring = poolsInPortfolio.reduce((acc, poolId, index) => {
acc[poolId] = PIE_CHART_DEFAULT_COLOR_SET[index % PIE_CHART_DEFAULT_COLOR_SET.length]!;
return acc;
}, {} as Record<Cardano.PoolId, string>);

const poolsNotInPortfolioColoring = poolsNotInPortfolio.reduce((acc, poolId, index) => {
acc[poolId] = GRAYSCALE_PALETTE[index % GRAYSCALE_PALETTE.length]!;
return acc;
}, {} as Record<Cardano.PoolId, string>);

return { ...poolsInPortfolioColoring, ...poolsNotInPortfolioColoring };
}, [currentPortfolio, rewardsByEpoch]);

return (poolId?: Cardano.PoolId) => (poolId ? coloring[poolId] : GRAYSCALE_PALETTE[0]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PastEpochsRewards } from './PastEpochsRewards';
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const RewardsHistory = ({ groupedRewardsActivities, walletActivitiesStatu

return (
<>
<Box mb="$16">
<Box mt="$48" mb="$16">
<Text.SubHeading>{t('activity.rewardsHistory.title')}</Text.SubHeading>
</Box>
<Skeleton loading={walletActivitiesStatus !== StateStatus.LOADED}>
Expand Down
6 changes: 6 additions & 0 deletions packages/staking/src/features/i18n/translations/en.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Translations } from '../types';

export const en: Translations = {
'activity.rewardsChart.all': 'All',
'activity.rewardsChart.epoch': 'Epoch',
'activity.rewardsChart.epochs': 'Epochs',
'activity.rewardsChart.last': 'Last',
'activity.rewardsChart.rewards': 'Rewards',
'activity.rewardsChart.title': 'Rewards',
'activity.rewardsHistory.noStakingActivityYet': 'No staking activity yet.',
'activity.rewardsHistory.title': 'History',
'browsePools.stakePoolTableBrowser.addPool': 'Add pool',
Expand Down
8 changes: 8 additions & 0 deletions packages/staking/src/features/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ type KeysStructure = {
title: '';
noStakingActivityYet: '';
};
rewardsChart: {
title: '';
epochs: '';
epoch: '';
last: '';
all: '';
rewards: '';
};
};
browsePools: {
stakePoolTableBrowser: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export type StateOverview = MakeState<{
export type StateActivity = MakeState<{
activeDelegationFlow: DelegationFlow.Activity;
activeDrawerStep: undefined;
draftPortfolio: undefined;
pendingSelectedPortfolio: undefined;
viewedStakePool: undefined;
}>;

export type StateCurrentPoolDetails = MakeState<{
Expand Down
Loading