feat(staking): stake distribution drift warning [LW-7966] (#533)
--------- Co-authored-by: Kamil Džurman <[email protected]>
--------- Co-authored-by: Kamil Džurman <[email protected]>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
<path d="M6 3.83268L10.6667 8.49935L6 13.166" stroke="#3D3B39" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@include popupViewContainer;
}
&.clickable {
cursor: pointer;
}
@media (max-width: $breakpoint-popup) {
@include popupViewContainer;
}
.chevronRightIconContainer {
align-self: center;
display: flex;
svg {
height: 16px;
width: 16px;
}
}
.iconContainer {
align-self: center;
display: flex;
@media (max-width: $breakpoint-popup) {
@include popupViewIconContainer;
}
&.withDescription {
margin-top: size_unit(1);
}
}
.contentContainer {
display: flex;
width: 100%;
width: 100%;
justify-content: space-between;
}
.buttonContainer {
.buttonContainer {
height: 60%;
padding-left: size_unit(1.25);
}
import { Typography } from 'antd';
import cn from 'classnames';
import { ReactComponent as DefaultIcon } from '../../assets/icons/banner-icon.component.svg';
import { ReactComponent as ChevronRight } from '../../assets/icons/chevron-right.component.svg';
import styles from './Banner.module.scss';
import { Button } from '../Button';
import { Link } from 'react-router-dom';
const shouldBeDisplayedAsText = (message: React.ReactNode) =>
typeof message === 'string' || typeof message === 'number';
export interface BannerProps {
export type BannerProps = {
withIcon?: boolean;
customIcon?: React.ReactElement;
message: string | React.ReactElement;
popupView?: boolean;
description?: React.ReactNode;
onLinkClick?: (event?: React.MouseEvent<HTMLButtonElement>) => unknown;
onButtonClick?: (event?: React.MouseEvent<HTMLButtonElement>) => unknown;
}
} & (
| { onButtonClick?: undefined; onBannerClick?: undefined }
| { onButtonClick?: (event?: React.MouseEvent<HTMLButtonElement>) => unknown; onBannerClick?: undefined }
| { onBannerClick?: (event?: React.MouseEvent<HTMLDivElement>) => unknown; onButtonClick?: undefined }
);
export const Banner = ({
message,
className,
descriptionClassName,
popupView,
onBannerClick,
onButtonClick,
linkMessage,
messagePartTwo,
);
return (
<div
className={cn(styles.bannerContainer, { [className]: className, [styles.popupView]: popupView })}
className={cn(styles.bannerContainer, {
[className]: className,
[styles.popupView]: popupView,
[styles.clickable]: !!onBannerClick
})}
data-testid="banner-container"
onClick={onBannerClick}
>
{withIcon && (
<div
{buttonMessage && <Button onClick={onButtonClick}> {buttonMessage} </Button>}
</div>
)}
{!!onBannerClick && (
<div className={cn(styles.chevronRightIconContainer)}>
<ChevronRight />
</div>
)}
</div>
</div>
);
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 17H20L18.5951 15.5951C18.2141 15.2141 18 14.6973 18 14.1585V11C18 8.38757 16.3304 6.16509 14 5.34142V5C14 3.89543 13.1046 3 12 3C10.8954 3 10 3.89543 10 5V5.34142C7.66962 6.16509 6 8.38757 6 11V14.1585C6 14.6973 5.78595 15.2141 5.40493 15.5951L4 17H9M15 17V18C15 19.6569 13.6569 21 12 21C10.3431 21 9 19.6569 9 18V17M15 17H9" stroke="#7F5AF0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
// eslint-disable-next-line import/no-extraneous-dependencies
import { formatPercentages } from '@lace/common';
import { Box, Card, ControlButton, Flex, PieChartColor, Text } from '@lace/ui';
import { useTranslation } from 'react-i18next';
import { useOutsideHandles } from '../outside-handles-provider';
interface PoolDetailsCardProps {
name: string;
color: PieChartColor;
weight: number;
percentage: number;
onRemove?: () => void;
}
export const PoolDetailsCard = ({ name, color, weight, onRemove }: PoolDetailsCardProps) => {
export const PoolDetailsCard = ({ name, color, percentage, onRemove }: PoolDetailsCardProps) => {
const { t } = useTranslation();
const { compactNumber, balancesBalance } = useOutsideHandles();
const stakeValue = balancesBalance
? // eslint-disable-next-line no-magic-numbers
compactNumber((weight / 100) * Number(balancesBalance.available.coinBalance))
compactNumber(percentage * Number(balancesBalance.available.coinBalance))
: '-';
return (
<Flex justifyContent="space-between" alignItems="center">
<Text.Body.Normal weight="$semibold">
{t('drawer.preferences.stakeValue', {
stakePercentage: weight,
stakePercentage: formatPercentages(percentage, { decimalPlaces: 0, rounding: 'halfUp' }),
stakeValue,
})}
</Text.Body.Normal>
>
<ItemStatRenderer
img={stakePool.displayData.logo}
text={stakePool.name || '-'}
subText={<span>{stakePool.ticker}</span>}
text={stakePool.displayData.name || '-'}
subText={<span>{stakePool.displayData.ticker}</span>}
/>
<div className={styles.itemData}>
<Ellipsis beforeEllipsis={10} afterEllipsis={8} text={stakePool.id} ellipsisInTheMiddle />
try {
setIsBuildingTx(true);
const txBuilder = inMemoryWallet.createTxBuilder();
const pools = draftPortfolio.map((pool) => ({ id: pool.id, weight: pool.weight }));
const pools = draftPortfolio.map((pool) => ({ id: pool.id, weight: pool.targetWeight }));
const tx = await txBuilder.delegatePortfolio({ pools }).build().inspect();
const implicitCoin = Wallet.Cardano.util.computeImplicitCoin(protocolParameters, tx.body);
const newDelegationTxDeposit = implicitCoin.deposit;
/* eslint-disable unicorn/consistent-destructuring */
import { Wallet } from '@lace/cardano';
import { Button, ControlButton, Flex, PIE_CHART_DEFAULT_COLOR_SET, PieChartColor, Text } from '@lace/ui';
import { useTranslation } from 'react-i18next';
portfolioMutators: state.mutators,
}));
const displayData = draftPortfolio.map(({ name = '-', weight, id }, i) => ({
color: PIE_CHART_DEFAULT_COLOR_SET[i] as PieChartColor,
id,
name,
weight,
}));
const targetWeightsSum = draftPortfolio.map(({ targetWeight }) => targetWeight).reduce((a, b) => a + b, 0);
const displayData = draftPortfolio.map((draftPool, i) => {
const {
displayData: { name },
id,
targetWeight,
} = draftPool;
return {
color: PIE_CHART_DEFAULT_COLOR_SET[i] as PieChartColor,
id,
name: name || '-',
percentage: draftPool.basedOnCurrentPortfolio
? draftPool.currentPortfolioPercentage
: targetWeight / targetWeightsSum,
};
});
const createRemovePoolFromPortfolio = (poolId: Wallet.Cardano.PoolIdHex) => () => {
portfolioMutators.executeCommand({
data: poolId,
/>
</Flex>
<Flex flexDirection="column" gap="$16" pb="$32" alignItems="stretch">
{displayData.map(({ name, id, color, weight }) => (
{displayData.map(({ name, id, color, percentage }) => (
<PoolDetailsCard
key={id}
name={name}
color={color}
weight={weight}
percentage={percentage}
onRemove={draftPortfolio.length > 1 ? createRemovePoolFromPortfolio(id) : undefined}
/>
))}
'overview.banners.pendingPoolMigration.message':
'You will continue to receive rewards from your former stake pool(s) for two epochs',
'overview.banners.pendingPoolMigration.title': 'You are migrating stake pool(s)',
'overview.banners.portfolioDrifted.message':
'Make sure to rebalance your staking ratios if you want to match your preferences',
'overview.banners.portfolioDrifted.title': 'Your current delegation portfolio has shifted',
'overview.delegationCard.label.balance': 'ADA Balance',
'overview.delegationCard.label.pools': 'Pool(s)',
'overview.delegationCard.label.status': 'Status',
title: '';
message: '';
};
portfolioDrifted: {
title: '';
message: '';
};
};
stakingInfoCard: {
fee: '';
arrangement="horizontal"
balance="10000"
cardanoCoinSymbol="ADA"
distribution={[{ color: PieChartGradientColor.LaceLinearGradient, name: 'A', weight: 1 }]}
distribution={[{ color: PieChartGradientColor.LaceLinearGradient, name: 'A', percentage: 1 }]}
status="ready"
/>
<hr />
<DelegationCard
arrangement="vertical"
balance="10000"
cardanoCoinSymbol="ADA"
distribution={[{ color: PieChartGradientColor.LaceLinearGradient, name: 'A', weight: 1 }]}
distribution={[{ color: PieChartGradientColor.LaceLinearGradient, name: 'A', percentage: 1 }]}
status="ready"
/>
</>
cardanoCoinSymbol: string;
distribution: Array<{
name: string;
weight: number;
percentage: number;
color: PieChartColor;
}>;
status: DelegationStatus;
data-testid="delegation-info-card"
>
<div className={styles.chart} data-testid="delegation-chart">
<PieChart data={distribution} nameKey="name" valueKey="weight" />
<PieChart data={distribution} nameKey="name" valueKey="percentage" />
{showDistribution && <Text.SubHeading className={styles.counter}>100%</Text.SubHeading>}
</div>
<div
import { InfoCircleOutlined } from '@ant-design/icons';
import { Banner, useObservable } from '@lace/common';
import { useObservable } from '@lace/common';
import { Box, ControlButton, Flex, Text } from '@lace/ui';
import { Skeleton } from 'antd';
import { useTranslation } from 'react-i18next';
import { FundWalletBanner } from './FundWalletBanner';
import { GetStartedSteps } from './GetStartedSteps';
import { hasMinimumFundsToDelegate, hasPendingDelegationTransaction, mapPortfolioToDisplayData } from './helpers';
import * as styles from './Overview.css';
import { StakeFundsBanner } from './StakeFundsBanner';
import { StakingInfoCard } from './staking-info-card';
import { StakingNotificationBanner, getCurrentStakingNotification } from './StakingNotificationBanner';
export const Overview = () => {
const { t } = useTranslation();
currentPortfolio: store.currentPortfolio,
portfolioMutators: store.mutators,
}));
const stakingNotification = getCurrentStakingNotification({ currentPortfolio, walletActivities });
const totalCoinBalance = balancesBalance?.total?.coinBalance;
if (
if (currentPortfolio.length === 0)
return (
<>
{pendingDelegationTransaction ? (
<Banner
withIcon
customIcon={<InfoCircleOutlined className={styles.bannerInfoIcon} />}
message={t('overview.banners.pendingFirstDelegation.title')}
description={t('overview.banners.pendingFirstDelegation.message')}
{stakingNotification ? (
<StakingNotificationBanner
notification={stakingNotification}
onPortfolioDriftedNotificationClick={onManageClick}
/>
) : (
<Flex flexDirection="column" gap="$32">
<DelegationCard
balance={compactNumber(balancesBalance.available.coinBalance)}
cardanoCoinSymbol={walletStoreWalletUICardanoCoin.symbol}
distribution={displayData}
distribution={displayData.map(({ color, name = '-', percentage }) => ({
color,
name,
percentage,
}))}
status={currentPortfolio.length === 1 ? 'simple-delegation' : 'multi-delegation'}
/>
</Box>
{pendingDelegationTransaction && (
{stakingNotification && (
<Box mb="$40">
<Banner
withIcon
customIcon={<InfoCircleOutlined className={styles.bannerInfoIcon} />}
message={t('overview.banners.pendingPoolMigration.title')}
description={t('overview.banners.pendingPoolMigration.message')}
<StakingNotificationBanner
notification={stakingNotification}
onPortfolioDriftedNotificationClick={onManageClick}
/>
</Box>
)}
import { hasMinimumFundsToDelegate, mapPortfolioToDisplayData } from './helpers';
import { StakeFundsBanner } from './StakeFundsBanner';
import { StakingInfoCard } from './staking-info-card';
import { StakingNotificationBanner, getCurrentStakingNotification } from './StakingNotificationBanner';
export const OverviewPopup = () => {
const { t } = useTranslation();
fetchCoinPricePriceResult,
walletAddress,
walletStoreInMemoryWallet: inMemoryWallet,
walletStoreWalletActivities: walletActivities,
expandStakingView,
} = useOutsideHandles();
const rewardAccounts = useObservable(inMemoryWallet.delegation.rewardAccounts$);
const protocolParameters = useObservable(inMemoryWallet.protocolParameters$);
const { currentPortfolio, portfolioMutators } = useDelegationPortfolioStore((store) => ({
currentPortfolio: store.currentPortfolio,
portfolioMutators: store.mutators,
}));
const stakingNotification = getCurrentStakingNotification({ currentPortfolio, walletActivities });
const totalCoinBalance = balancesBalance?.total?.coinBalance || '0';
return (
<>
{stakingNotification === 'portfolioDrifted' && (
<Box mb="$32">
<StakingNotificationBanner
notification="portfolioDrifted"
onPortfolioDriftedNotificationClick={expandStakingView}
/>
</Box>
)}
<Box mb="$32">
<DelegationCard
balance={compactNumber(balancesBalance.available.coinBalance)}
cardanoCoinSymbol={walletStoreWalletUICardanoCoin.symbol}
arrangement="vertical"
distribution={displayData}
distribution={displayData.map(({ color, name = '-', percentage }) => ({
color,
name,
percentage,
}))}
status={currentPortfolio.length === 1 ? 'simple-delegation' : 'multi-delegation'}
/>
</Box>
import { style } from '@vanilla-extract/css';
import { theme } from '../theme';
import { theme } from '../../theme';
export const bannerInfoIcon = style({
color: theme.colors.$bannerInfoIconColor,
});
export const bannerBellIcon = style({
color: theme.colors.$bannerBellIconColor,
});
import { Banner } from '@lace/common';
import InfoIcon from '@lace/core/src/ui/assets/icons/info-icon.component.svg';
import { useTranslation } from 'react-i18next';
import BellIcon from '../../../assets/icons/bell-icon.component.svg';
import * as styles from './StakingNotificationBanner.css';
import { StakingNotificationType } from './types';
type StakingNotificationBannerProps = {
popupView?: boolean;
notification: StakingNotificationType;
onPortfolioDriftedNotificationClick?: () => void;
};
export const StakingNotificationBanner = ({
popupView,
notification,
onPortfolioDriftedNotificationClick,
}: StakingNotificationBannerProps) => {
const { t } = useTranslation();
switch (notification) {
case 'pendingFirstDelegation':
return (
<Banner
popupView={popupView}
withIcon
customIcon={<InfoIcon className={styles.bannerInfoIcon} />}
message={t('overview.banners.pendingFirstDelegation.title')}
description={t('overview.banners.pendingFirstDelegation.message')}
/>
);
case 'pendingPoolMigration':
return (
<Banner
popupView={popupView}
withIcon
customIcon={<InfoIcon className={styles.bannerInfoIcon} />}
message={t('overview.banners.pendingPoolMigration.title')}
description={t('overview.banners.pendingPoolMigration.message')}
/>
);
case 'portfolioDrifted':
return (
<Banner
popupView={popupView}
withIcon
customIcon={<BellIcon className={styles.bannerInfoIcon} />}
message={t('overview.banners.portfolioDrifted.title')}
description={t('overview.banners.portfolioDrifted.message')}
onBannerClick={onPortfolioDriftedNotificationClick}
/>
);
default:
return <></>;
}
};
import type { CurrentPortfolioStakePool } from '../../store';
import type { StakingNotificationType } from './types';
import type { AssetActivityListProps } from '@lace/core';
import { hasPendingDelegationTransaction, isPortfolioDrifted } from '../helpers';
type GetCurrentStakingNotificationParams = {
walletActivities: AssetActivityListProps[];
currentPortfolio: CurrentPortfolioStakePool[];
};
export const getCurrentStakingNotification = ({
walletActivities,
currentPortfolio,
}: GetCurrentStakingNotificationParams): StakingNotificationType | null => {
const pendingDelegationTransaction = hasPendingDelegationTransaction(walletActivities);
if (pendingDelegationTransaction) {
return currentPortfolio.length === 0 ? 'pendingFirstDelegation' : 'pendingPoolMigration';
}
if (isPortfolioDrifted(currentPortfolio)) {
return 'portfolioDrifted';
}
return null;
};
export * from './StakingNotificationBanner';
export * from './getCurrentStakingNotification';
export * from './types';
export type StakingNotificationType = 'pendingFirstDelegation' | 'pendingPoolMigration' | 'portfolioDrifted';
export { hasMinimumFundsToDelegate } from './hasMinimumFundsToDelegate';
export { hasPendingDelegationTransaction } from './hasPendingDelegationTransaction';
export { mapPortfolioToDisplayData } from './mapPortfolioToDisplayData';
export { isPortfolioDrifted } from './isPortfolioDrifted';
/* eslint-disable no-magic-numbers */
import { Wallet } from '@lace/cardano';
import BigNumber from 'bignumber.js';
import sum from 'lodash/sum';
import type { CurrentPortfolioStakePool } from '../../store';
const PORTFOLIO_DRIFT_PERCENTAGE_THRESHOLD = 15;
const getPortfolioTotalPercentageDrift = (portfolio: CurrentPortfolioStakePool[]): number => {
const totalValue = Wallet.BigIntMath.sum(portfolio.map(({ value }) => value));
const totalWeight = sum(portfolio.map(({ targetWeight }) => targetWeight));
return sum(
portfolio.map(({ value, targetWeight }) => {
const targetPercentage = (targetWeight / totalWeight) * 100;
const currentPercentage = new BigNumber(value.toString()).div(totalValue.toString()).times(100).toNumber();
return Math.abs(targetPercentage - currentPercentage);
})
);
};
export const isPortfolioDrifted = (currentPortfolio: CurrentPortfolioStakePool[]) => {
const drift = getPortfolioTotalPercentageDrift(currentPortfolio);
return drift >= PORTFOLIO_DRIFT_PERCENTAGE_THRESHOLD;
};
Alonzo builds