From d2c04ac282d28604c9c857606e607a53319c73b6 Mon Sep 17 00:00:00 2001 From: Panteleymonchuk Date: Wed, 11 Dec 2024 17:19:11 +0200 Subject: [PATCH] feat(wallet-dashboard): style staked vesting overview (#4303) * feat(dashboard): style vesting * feat(dashboard): show properly data * fix(wallet-dashboard): wrong buttonType for dist * feat(dashboard): update UI after resolve conflicts. * feat(dashboard): add earned amount * feat(wallet-dashboard): implement StakedTimelockObject component and refactor vesting page * feat(dashboard): add Stake button to Staked Vesting title and simplify StakeDialog prop * feat(dashboard): add useStakeRewardStatus hook and integrate into StakedTimelockObject component * feat(vesting): update vesting interface and calculations to use bigint for improved precision * feat(dashboard): add loader, change style, fix BigInt round issue. * refactor(dashboard): move StakeState and STATUS_COPY to useStakeRewardStatus hook * feat(dashboard): implement bigInt to tests. * fix(dashboard): adjust padding for sidebar overlap on small screens and improve flex layout in vesting page * feat(dashboard): update estimatedReward and principal to use BigInt for improved precision --- apps/core/src/components/stake/StakedCard.tsx | 78 +---- apps/core/src/hooks/index.ts | 1 + apps/core/src/hooks/useStakeRewardStatus.ts | 92 ++++++ .../app/(protected)/layout.tsx | 11 +- .../app/(protected)/vesting/page.tsx | 272 ++++++++++-------- apps/wallet-dashboard/components/index.ts | 1 + .../StakedTimelockObject.tsx | 74 +++++ .../staked-timelock-object/index.ts | 4 + .../lib/interfaces/vesting.interface.ts | 13 +- .../lib/utils/vesting/vesting.spec.ts | 78 +++-- .../lib/utils/vesting/vesting.ts | 40 ++- 11 files changed, 426 insertions(+), 238 deletions(-) create mode 100644 apps/core/src/hooks/useStakeRewardStatus.ts create mode 100644 apps/wallet-dashboard/components/staked-timelock-object/StakedTimelockObject.tsx create mode 100644 apps/wallet-dashboard/components/staked-timelock-object/index.ts diff --git a/apps/core/src/components/stake/StakedCard.tsx b/apps/core/src/components/stake/StakedCard.tsx index dad5c6f091a..58bc5c82f20 100644 --- a/apps/core/src/components/stake/StakedCard.tsx +++ b/apps/core/src/components/stake/StakedCard.tsx @@ -7,27 +7,10 @@ import { Card, CardImage, CardType, CardBody, CardAction, CardActionType } from import { useMemo } from 'react'; import { useIotaClientQuery } from '@iota/dapp-kit'; import { ImageIcon } from '../icon'; -import { determineCountDownText, ExtendedDelegatedStake } from '../../utils'; -import { TimeUnit, useFormatCoin, useGetTimeBeforeEpochNumber, useTimeAgo } from '../../hooks'; -import { NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_REDEEMABLE } from '../../constants'; +import { ExtendedDelegatedStake } from '../../utils'; +import { useFormatCoin, useStakeRewardStatus } from '../../hooks'; import React from 'react'; -export enum StakeState { - WarmUp = 'WARM_UP', - Earning = 'EARNING', - CoolDown = 'COOL_DOWN', - Withdraw = 'WITHDRAW', - InActive = 'IN_ACTIVE', -} - -const STATUS_COPY: { [key in StakeState]: string } = { - [StakeState.WarmUp]: 'Starts Earning', - [StakeState.Earning]: 'Staking Rewards', - [StakeState.CoolDown]: 'Available to withdraw', - [StakeState.Withdraw]: 'Withdraw', - [StakeState.InActive]: 'Inactive', -}; - interface StakedCardProps { extendedStake: ExtendedDelegatedStake; currentEpoch: number; @@ -45,48 +28,20 @@ export function StakedCard({ }: StakedCardProps) { const { principal, stakeRequestEpoch, estimatedReward, validatorAddress } = extendedStake; - // TODO: Once two step withdraw is available, add cool down and withdraw now logic - // For cool down epoch, show Available to withdraw add rewards to principal - // Reward earning epoch is 2 epochs after stake request epoch - const earningRewardsEpoch = - Number(stakeRequestEpoch) + NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_REDEEMABLE; - const isEarnedRewards = currentEpoch >= Number(earningRewardsEpoch); - const delegationState = inactiveValidator - ? StakeState.InActive - : isEarnedRewards - ? StakeState.Earning - : StakeState.WarmUp; - - const rewards = isEarnedRewards && estimatedReward ? BigInt(estimatedReward) : 0n; + const { rewards, title, subtitle } = useStakeRewardStatus({ + stakeRequestEpoch, + currentEpoch, + estimatedReward, + inactiveValidator, + }); // For inactive validator, show principal + rewards const [principalStaked, symbol] = useFormatCoin( inactiveValidator ? principal + rewards : principal, IOTA_TYPE_ARG, ); - const [rewardsStaked] = useFormatCoin(rewards, IOTA_TYPE_ARG); - - // Applicable only for warm up - const epochBeforeRewards = delegationState === StakeState.WarmUp ? earningRewardsEpoch : null; - - const statusText = { - // Epoch time before earning - [StakeState.WarmUp]: `Epoch #${earningRewardsEpoch}`, - [StakeState.Earning]: `${rewardsStaked} ${symbol}`, - // Epoch time before redrawing - [StakeState.CoolDown]: `Epoch #`, - [StakeState.Withdraw]: 'Now', - [StakeState.InActive]: 'Not earning rewards', - }; const { data } = useIotaClientQuery('getLatestIotaSystemState'); - const { data: rewardEpochTime } = useGetTimeBeforeEpochNumber(Number(epochBeforeRewards) || 0); - const timeAgo = useTimeAgo({ - timeFrom: rewardEpochTime || null, - shortedTimeLabel: false, - shouldEnd: true, - maxTimeUnit: TimeUnit.ONE_HOUR, - }); const validatorMeta = useMemo(() => { if (!data) return null; @@ -97,17 +52,6 @@ export function StakedCard({ ); }, [validatorAddress, data]); - const rewardTime = () => { - if (Number(epochBeforeRewards) && rewardEpochTime > 0) { - return determineCountDownText({ - timeAgo, - label: 'in', - }); - } - - return statusText[delegationState]; - }; - return ( @@ -118,11 +62,7 @@ export function StakedCard({ /> - + ); } diff --git a/apps/core/src/hooks/index.ts b/apps/core/src/hooks/index.ts index 122b9b01294..89aced3f57f 100644 --- a/apps/core/src/hooks/index.ts +++ b/apps/core/src/hooks/index.ts @@ -46,5 +46,6 @@ export * from './useNFTBasicData'; export * from './useOwnedNFT'; export * from './useNftDetails'; export * from './useCountdownByTimestamp'; +export * from './useStakeRewardStatus'; export * from './stake'; diff --git a/apps/core/src/hooks/useStakeRewardStatus.ts b/apps/core/src/hooks/useStakeRewardStatus.ts new file mode 100644 index 00000000000..415b3efc5ac --- /dev/null +++ b/apps/core/src/hooks/useStakeRewardStatus.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; +import { NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_REDEEMABLE } from '../constants'; +import { useFormatCoin, useGetTimeBeforeEpochNumber, useTimeAgo, TimeUnit } from '.'; +import { determineCountDownText } from '../utils'; + +export function useStakeRewardStatus({ + stakeRequestEpoch, + currentEpoch, + inactiveValidator, + estimatedReward, +}: { + stakeRequestEpoch: string; + currentEpoch: number; + inactiveValidator: boolean; + estimatedReward?: string | number | bigint; +}) { + // TODO: Once two step withdraw is available, add cool down and withdraw now logic + // For cool down epoch, show Available to withdraw add rewards to principal + // Reward earning epoch is 2 epochs after stake request epoch + const earningRewardsEpoch = + Number(stakeRequestEpoch) + NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_REDEEMABLE; + + const isEarnedRewards = currentEpoch >= Number(earningRewardsEpoch); + + const delegationState = inactiveValidator + ? StakeState.InActive + : isEarnedRewards + ? StakeState.Earning + : StakeState.WarmUp; + + const rewards = isEarnedRewards && estimatedReward ? BigInt(estimatedReward) : 0n; + + const [rewardsStaked, symbol] = useFormatCoin(rewards, IOTA_TYPE_ARG); + + // Applicable only for warm up + const epochBeforeRewards = delegationState === StakeState.WarmUp ? earningRewardsEpoch : null; + + const statusText = { + // Epoch time before earning + [StakeState.WarmUp]: `Epoch #${earningRewardsEpoch}`, + [StakeState.Earning]: `${rewardsStaked} ${symbol}`, + // Epoch time before redrawing + [StakeState.CoolDown]: `Epoch #`, + [StakeState.Withdraw]: 'Now', + [StakeState.InActive]: 'Not earning rewards', + }; + + const { data: rewardEpochTime } = useGetTimeBeforeEpochNumber(Number(epochBeforeRewards) || 0); + + const timeAgo = useTimeAgo({ + timeFrom: rewardEpochTime || null, + shortedTimeLabel: false, + shouldEnd: true, + maxTimeUnit: TimeUnit.ONE_HOUR, + }); + + const rewardTime = () => { + if (Number(epochBeforeRewards) && rewardEpochTime > 0) { + return determineCountDownText({ + timeAgo, + label: 'in', + }); + } + + return statusText[delegationState]; + }; + + return { + rewards, + title: rewardTime(), + subtitle: STATUS_COPY[delegationState], + }; +} +export enum StakeState { + WarmUp = 'WARM_UP', + Earning = 'EARNING', + CoolDown = 'COOL_DOWN', + Withdraw = 'WITHDRAW', + InActive = 'IN_ACTIVE', +} +export const STATUS_COPY: { + [key in StakeState]: string; +} = { + [StakeState.WarmUp]: 'Starts Earning', + [StakeState.Earning]: 'Staking Rewards', + [StakeState.CoolDown]: 'Available to withdraw', + [StakeState.Withdraw]: 'Withdraw', + [StakeState.InActive]: 'Inactive', +}; diff --git a/apps/wallet-dashboard/app/(protected)/layout.tsx b/apps/wallet-dashboard/app/(protected)/layout.tsx index 8d42d2ff757..a77c8922ab9 100644 --- a/apps/wallet-dashboard/app/(protected)/layout.tsx +++ b/apps/wallet-dashboard/app/(protected)/layout.tsx @@ -25,11 +25,14 @@ function DashboardLayout({ children }: PropsWithChildren): JSX.Element { -
-
- + {/* This padding need to have aligned left/right content's position, because of sidebar overlap on the small screens */} +
+
+
+ +
+
{children}
-
{children}
diff --git a/apps/wallet-dashboard/app/(protected)/vesting/page.tsx b/apps/wallet-dashboard/app/(protected)/vesting/page.tsx index 0488681830d..b925d57e97c 100644 --- a/apps/wallet-dashboard/app/(protected)/vesting/page.tsx +++ b/apps/wallet-dashboard/app/(protected)/vesting/page.tsx @@ -6,6 +6,7 @@ import { Banner, StakeDialog, + StakeDialogView, TimelockedUnstakePopup, useStakeDialog, VestingScheduleDialog, @@ -37,8 +38,9 @@ import { CardType, ImageType, ImageShape, - ButtonType, Button, + ButtonType, + LoadingIndicator, } from '@iota/apps-ui-kit'; import { Theme, @@ -52,19 +54,26 @@ import { useCountdownByTimestamp, Feature, } from '@iota/core'; -import { useCurrentAccount, useIotaClient, useSignAndExecuteTransaction } from '@iota/dapp-kit'; +import { + useCurrentAccount, + useIotaClient, + useIotaClientQuery, + useSignAndExecuteTransaction, +} from '@iota/dapp-kit'; import { IotaValidatorSummary } from '@iota/iota-sdk/client'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; import { Calendar, StarHex } from '@iota/ui-icons'; import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; +import { StakedTimelockObject } from '@/components'; function VestingDashboardPage(): JSX.Element { const account = useCurrentAccount(); const queryClient = useQueryClient(); const iotaClient = useIotaClient(); const router = useRouter(); + const { data: system } = useIotaClientQuery('getLatestIotaSystemState'); const [isVestingScheduleDialogOpen, setIsVestingScheduleDialogOpen] = useState(false); const { addNotification } = useNotifications(); const { openPopup, closePopup } = usePopups(); @@ -73,7 +82,10 @@ function VestingDashboardPage(): JSX.Element { const { data: timelockedObjects } = useGetAllOwnedObjects(account?.address || '', { StructType: TIMELOCK_IOTA_TYPE, }); - const { data: timelockedStakedObjects } = useGetTimelockedStakedObjects(account?.address || ''); + + const { data: timelockedStakedObjects, isLoading: istimelockedStakedObjectsLoading } = + useGetTimelockedStakedObjects(account?.address || ''); + const { mutateAsync: signAndExecuteTransaction } = useSignAndExecuteTransaction(); const { theme } = useTheme(); @@ -151,6 +163,16 @@ function VestingDashboardPage(): JSX.Element { ); } + const [totalStakedFormatted, totalStakedSymbol] = useFormatCoin( + vestingSchedule.totalStaked, + IOTA_TYPE_ARG, + ); + + const [totalEarnedFormatted, totalEarnedSymbol] = useFormatCoin( + vestingSchedule.totalEarned, + IOTA_TYPE_ARG, + ); + const unlockedTimelockedObjects = timelockedMapped?.filter((timelockedObject) => isTimelockedUnlockable(timelockedObject, Number(currentEpochMs)), ); @@ -232,75 +254,85 @@ function VestingDashboardPage(): JSX.Element { router.push('/'); } }, [router, supplyIncreaseVestingEnabled]); + + if (istimelockedStakedObjectsLoading) { + return ( +
+ +
+ ); + } + return ( -
- - - <div className="flex flex-col gap-md p-lg pt-sm"> - <div className="flex h-24 flex-row gap-4"> - <DisplayStats - label="Total Vested" - value={formattedTotalVested} - supportingLabel={vestedSymbol} - /> - <DisplayStats - label="Total Locked" - value={formattedTotalLocked} - supportingLabel={lockedSymbol} - tooltipText="Total amount of IOTA that is still locked in your account." - tooltipPosition={TooltipPosition.Right} - /> + <div className="flex w-full max-w-4xl flex-col items-stretch justify-center gap-lg justify-self-center md:flex-row"> + <div className="flex w-full flex-col gap-lg md:w-1/2"> + <Panel> + <Title title="Vesting" size={TitleSize.Medium} /> + <div className="flex flex-col gap-md p-lg pt-sm"> + <div className="flex h-24 flex-row gap-4"> + <DisplayStats + label="Total Vested" + value={formattedTotalVested} + supportingLabel={vestedSymbol} + /> + <DisplayStats + label="Total Locked" + value={formattedTotalLocked} + supportingLabel={lockedSymbol} + tooltipText="Total amount of IOTA that is still locked in your account." + tooltipPosition={TooltipPosition.Right} + /> + </div> + <Card type={CardType.Outlined}> + <CardImage type={ImageType.BgSolid} shape={ImageShape.SquareRounded}> + <StarHex className="h-5 w-5 text-primary-30 dark:text-primary-80" /> + </CardImage> + <CardBody + title={`${formattedAvailableClaiming} ${availableClaimingSymbol}`} + subtitle="Available Rewards" + /> + <CardAction + type={CardActionType.Button} + onClick={handleCollect} + title="Collect" + buttonType={ButtonType.Primary} + buttonDisabled={ + !vestingSchedule.availableClaiming || + vestingSchedule.availableClaiming === 0n + } + /> + </Card> + <Card type={CardType.Outlined}> + <CardImage type={ImageType.BgSolid} shape={ImageShape.SquareRounded}> + <Calendar className="h-5 w-5 text-primary-30 dark:text-primary-80" /> + </CardImage> + <CardBody + title={`${formattedNextPayout} ${nextPayoutSymbol}`} + subtitle={`Next payout ${ + nextPayout?.expirationTimestampMs + ? formattedLastPayoutExpirationTime + : '' + }`} + /> + <CardAction + type={CardActionType.Button} + onClick={openReceiveTokenPopup} + title="See All" + buttonType={ButtonType.Secondary} + buttonDisabled={!vestingPortfolio} + /> + </Card> + {vestingPortfolio && ( + <VestingScheduleDialog + open={isVestingScheduleDialogOpen} + setOpen={setIsVestingScheduleDialogOpen} + vestingPortfolio={vestingPortfolio} + /> + )} </div> - <Card type={CardType.Outlined}> - <CardImage type={ImageType.BgSolid} shape={ImageShape.SquareRounded}> - <StarHex className="h-5 w-5 text-primary-30 dark:text-primary-80" /> - </CardImage> - <CardBody - title={`${formattedAvailableClaiming} ${availableClaimingSymbol}`} - subtitle="Available Rewards" - /> - <CardAction - type={CardActionType.Button} - onClick={handleCollect} - title="Collect" - buttonType={ButtonType.Primary} - buttonDisabled={ - !vestingSchedule.availableClaiming || - vestingSchedule.availableClaiming === 0 - } - /> - </Card> - <Card type={CardType.Outlined}> - <CardImage type={ImageType.BgSolid} shape={ImageShape.SquareRounded}> - <Calendar className="h-5 w-5 text-primary-30 dark:text-primary-80" /> - </CardImage> - <CardBody - title={`${formattedNextPayout} ${nextPayoutSymbol}`} - subtitle={`Next payout ${ - nextPayout?.expirationTimestampMs - ? formattedLastPayoutExpirationTime - : '' - }`} - /> - <CardAction - type={CardActionType.Button} - onClick={openReceiveTokenPopup} - title="See All" - buttonType={ButtonType.Secondary} - buttonDisabled={!vestingPortfolio} - /> - </Card> - {vestingPortfolio && ( - <VestingScheduleDialog - open={isVestingScheduleDialogOpen} - setOpen={setIsVestingScheduleDialogOpen} - vestingPortfolio={vestingPortfolio} - /> - )} - </div> - </Panel> - {timelockedstakedMapped.length === 0 ? ( - <> + </Panel> + + {timelockedstakedMapped.length === 0 ? ( <Banner videoSrc={videoSrc} title="Stake Vested Tokens" @@ -308,56 +340,64 @@ function VestingDashboardPage(): JSX.Element { onButtonClick={() => handleNewStake()} buttonText="Stake" /> - </> - ) : ( - <div className="flex w-1/2 flex-col items-center justify-center space-y-4 pt-12"> - <h1>Staked Vesting</h1> - <div className="flex flex-row space-x-4"> - <div className="flex flex-col items-center rounded-lg border p-4"> - <span>Your stake</span> - <span>{vestingSchedule.totalStaked}</span> + ) : null} + </div> + + {timelockedstakedMapped.length !== 0 ? ( + <div className="flex w-full md:w-1/2"> + <Panel> + <Title + title="Staked Vesting" + trailingElement={ + <Button + type={ButtonType.Primary} + text="Stake" + onClick={() => { + setStakeDialogView(StakeDialogView.SelectValidator); + }} + /> + } + /> + + <div className="flex flex-col px-lg py-sm"> + <div className="flex flex-row gap-md"> + <DisplayStats + label="Your stake" + value={`${totalStakedFormatted} ${totalStakedSymbol}`} + /> + <DisplayStats + label="Earned" + value={`${totalEarnedFormatted} ${totalEarnedSymbol}`} + /> + </div> </div> - <div className="flex flex-col items-center rounded-lg border p-4"> - <span>Total Unlocked</span> - <span>{vestingSchedule.totalUnlocked}</span> + <div className="flex flex-col px-lg py-sm"> + <div className="flex w-full flex-col items-center justify-center space-y-4 pt-4"> + {system && + timelockedStakedObjectsGrouped?.map( + (timelockedStakedObject) => { + return ( + <StakedTimelockObject + key={ + timelockedStakedObject.validatorAddress + + timelockedStakedObject.stakeRequestEpoch + + timelockedStakedObject.label + } + getValidatorByAddress={getValidatorByAddress} + timelockedStakedObject={timelockedStakedObject} + handleUnstake={handleUnstake} + currentEpoch={Number(system.epoch)} + /> + ); + }, + )} + </div> </div> - </div> - <div className="flex w-full flex-col items-center justify-center space-y-4 pt-4"> - {timelockedStakedObjectsGrouped?.map((timelockedStakedObject) => { - return ( - <div - key={ - timelockedStakedObject.validatorAddress + - timelockedStakedObject.stakeRequestEpoch + - timelockedStakedObject.label - } - className="flex w-full flex-row items-center justify-center space-x-4" - > - <span> - Validator:{' '} - {getValidatorByAddress( - timelockedStakedObject.validatorAddress, - )?.name || timelockedStakedObject.validatorAddress} - </span> - <span> - Stake Request Epoch:{' '} - {timelockedStakedObject.stakeRequestEpoch} - </span> - <span>Stakes: {timelockedStakedObject.stakes.length}</span> - - <Button - onClick={() => handleUnstake(timelockedStakedObject)} - text="Unstake" - /> - </div> - ); - })} - </div> - <Button onClick={() => handleNewStake()} text="Stake" /> + </Panel> </div> - )} + ) : null} <StakeDialog - isTimelockedStaking={true} + isTimelockedStaking stakedDetails={selectedStake} onSuccess={handleOnSuccess} isOpen={isDialogStakeOpen} diff --git a/apps/wallet-dashboard/components/index.ts b/apps/wallet-dashboard/components/index.ts index 179f9e836ac..37ff834be4f 100644 --- a/apps/wallet-dashboard/components/index.ts +++ b/apps/wallet-dashboard/components/index.ts @@ -26,3 +26,4 @@ export * from './tiles'; export * from './Toaster'; export * from './Banner'; export * from './MigrationOverview'; +export * from './staked-timelock-object'; diff --git a/apps/wallet-dashboard/components/staked-timelock-object/StakedTimelockObject.tsx b/apps/wallet-dashboard/components/staked-timelock-object/StakedTimelockObject.tsx new file mode 100644 index 00000000000..8696705bc12 --- /dev/null +++ b/apps/wallet-dashboard/components/staked-timelock-object/StakedTimelockObject.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import { TimelockedStakedObjectsGrouped } from '@/lib/utils'; +import { Card, CardImage, CardBody, CardAction, CardActionType } from '@iota/apps-ui-kit'; +import { useFormatCoin, ImageIcon, ImageIconSize, useStakeRewardStatus } from '@iota/core'; +import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; +import { IotaValidatorSummary } from '@iota/iota-sdk/client'; + +export interface StakedTimelockObjectProps { + timelockedStakedObject: TimelockedStakedObjectsGrouped; + handleUnstake: (timelockedStakedObject: TimelockedStakedObjectsGrouped) => void; + getValidatorByAddress: (validatorAddress: string) => IotaValidatorSummary | undefined; + currentEpoch: number; +} + +export function StakedTimelockObject({ + getValidatorByAddress, + timelockedStakedObject, + handleUnstake, + currentEpoch, +}: StakedTimelockObjectProps) { + const name = + getValidatorByAddress(timelockedStakedObject.validatorAddress)?.name || + timelockedStakedObject.validatorAddress; + + // TODO probably we could calculate estimated reward on grouping stage. + const summary = timelockedStakedObject.stakes.reduce( + (acc, stake) => { + const estimatedReward = stake.status === 'Active' ? stake.estimatedReward : 0; + + return { + principal: BigInt(stake.principal) + acc.principal, + estimatedReward: BigInt(estimatedReward) + acc.estimatedReward, + stakeRequestEpoch: stake.stakeRequestEpoch, + }; + }, + { + principal: 0n, + estimatedReward: 0n, + stakeRequestEpoch: '', + }, + ); + + const supportingText = useStakeRewardStatus({ + currentEpoch, + stakeRequestEpoch: summary.stakeRequestEpoch, + estimatedReward: summary.estimatedReward, + inactiveValidator: false, + }); + + const [sumPrincipalFormatted, sumPrincipalSymbol] = useFormatCoin( + summary.principal, + IOTA_TYPE_ARG, + ); + + return ( + <Card onClick={() => handleUnstake(timelockedStakedObject)}> + <CardImage> + <ImageIcon src={null} label={name} fallback={name} size={ImageIconSize.Large} /> + </CardImage> + <CardBody + title={name} + subtitle={`${sumPrincipalFormatted} ${sumPrincipalSymbol}`} + isTextTruncated + /> + <CardAction + type={CardActionType.SupportingText} + title={supportingText.title} + subtitle={supportingText.subtitle} + /> + </Card> + ); +} diff --git a/apps/wallet-dashboard/components/staked-timelock-object/index.ts b/apps/wallet-dashboard/components/staked-timelock-object/index.ts new file mode 100644 index 00000000000..2be2613f3ef --- /dev/null +++ b/apps/wallet-dashboard/components/staked-timelock-object/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export * from './StakedTimelockObject'; diff --git a/apps/wallet-dashboard/lib/interfaces/vesting.interface.ts b/apps/wallet-dashboard/lib/interfaces/vesting.interface.ts index c997986f065..ba846d71e6c 100644 --- a/apps/wallet-dashboard/lib/interfaces/vesting.interface.ts +++ b/apps/wallet-dashboard/lib/interfaces/vesting.interface.ts @@ -14,10 +14,11 @@ export interface SupplyIncreaseVestingPayout { export type SupplyIncreaseVestingPortfolio = SupplyIncreaseVestingPayout[]; export interface VestingOverview { - totalVested: number; - totalUnlocked: number; - totalLocked: number; - totalStaked: number; - availableClaiming: number; - availableStaking: number; + totalVested: bigint; + totalUnlocked: bigint; + totalLocked: bigint; + totalStaked: bigint; + totalEarned: bigint; + availableClaiming: bigint; + availableStaking: bigint; } diff --git a/apps/wallet-dashboard/lib/utils/vesting/vesting.spec.ts b/apps/wallet-dashboard/lib/utils/vesting/vesting.spec.ts index 5e1cea77657..e0bb1f37bd5 100644 --- a/apps/wallet-dashboard/lib/utils/vesting/vesting.spec.ts +++ b/apps/wallet-dashboard/lib/utils/vesting/vesting.spec.ts @@ -21,6 +21,10 @@ import { const MOCKED_CURRENT_EPOCH_TIMESTAMP = Date.now() + MILLISECONDS_PER_HOUR * 6; // 6 hours later +function bigIntRound(n: number) { + return BigInt(Math.floor(n)); +} + describe('get last supply increase vesting payout', () => { it('should get the object with highest expirationTimestampMs', () => { const timelockedObjects = MOCKED_SUPPLY_INCREASE_VESTING_TIMELOCKED_OBJECTS; @@ -121,11 +125,12 @@ describe('vesting overview', () => { it('should get correct vesting overview data with timelocked objects', () => { const timelockedObjects = MOCKED_SUPPLY_INCREASE_VESTING_TIMELOCKED_OBJECTS; const lastPayout = timelockedObjects[timelockedObjects.length - 1]; - const totalAmount = + const totalAmount = bigIntRound( (SUPPLY_INCREASE_STAKER_VESTING_DURATION * SUPPLY_INCREASE_VESTING_PAYOUTS_IN_1_YEAR * lastPayout.locked.value) / - 0.9; + 0.9, + ); const vestingOverview = getVestingOverview(timelockedObjects, Date.now()); expect(vestingOverview.totalVested).toEqual(totalAmount); @@ -140,25 +145,31 @@ describe('vesting overview', () => { const lockedAmount = vestingPortfolio.reduce( (acc, current) => - current.expirationTimestampMs > Date.now() ? acc + current.amount : acc, - 0, + current.expirationTimestampMs > Date.now() + ? acc + bigIntRound(current.amount) + : acc, + 0n, ); expect(vestingOverview.totalLocked).toEqual(lockedAmount); expect(vestingOverview.totalUnlocked).toEqual(totalAmount - lockedAmount); // In this scenario there are no staked objects - expect(vestingOverview.totalStaked).toEqual(0); + expect(vestingOverview.totalStaked).toEqual(0n); const lockedObjectsAmount = timelockedObjects.reduce( (acc, current) => - current.expirationTimestampMs > Date.now() ? acc + current.locked.value : acc, - 0, + current.expirationTimestampMs > Date.now() + ? acc + bigIntRound(current.locked.value) + : acc, + 0n, ); const unlockedObjectsAmount = timelockedObjects.reduce( (acc, current) => - current.expirationTimestampMs <= Date.now() ? acc + current.locked.value : acc, - 0, + current.expirationTimestampMs <= Date.now() + ? acc + bigIntRound(current.locked.value) + : acc, + 0n, ); expect(vestingOverview.availableClaiming).toEqual(unlockedObjectsAmount); @@ -172,11 +183,13 @@ describe('vesting overview', () => { const lastPayout = extendedTimelockedStakedObjects[extendedTimelockedStakedObjects.length - 1]; const lastPayoutValue = Number(lastPayout.principal); - const totalAmount = + const totalAmount = bigIntRound( (SUPPLY_INCREASE_STAKER_VESTING_DURATION * SUPPLY_INCREASE_VESTING_PAYOUTS_IN_1_YEAR * lastPayoutValue) / - 0.9; + 0.9, + ); + const vestingOverview = getVestingOverview(extendedTimelockedStakedObjects, Date.now()); expect(vestingOverview.totalVested).toEqual(totalAmount); @@ -190,18 +203,20 @@ describe('vesting overview', () => { const lockedAmount = vestingPortfolio.reduce( (acc, current) => - current.expirationTimestampMs > Date.now() ? acc + current.amount : acc, - 0, + current.expirationTimestampMs > Date.now() + ? acc + bigIntRound(current.amount) + : acc, + 0n, ); expect(vestingOverview.totalLocked).toEqual(lockedAmount); expect(vestingOverview.totalUnlocked).toEqual(totalAmount - lockedAmount); - let totalStaked: number = 0; + let totalStaked = 0n; for (const timelockedStakedObject of timelockedStakedObjects) { const stakesAmount = timelockedStakedObject.stakes.reduce( - (acc, current) => acc + Number(current.principal), - 0, + (acc, current) => acc + bigIntRound(Number(current.principal)), + 0n, ); totalStaked += stakesAmount; } @@ -209,8 +224,8 @@ describe('vesting overview', () => { expect(vestingOverview.totalStaked).toEqual(totalStaked); // In this scenario there are no objects to stake or claim because they are all staked - expect(vestingOverview.availableClaiming).toEqual(0); - expect(vestingOverview.availableStaking).toEqual(0); + expect(vestingOverview.availableClaiming).toEqual(0n); + expect(vestingOverview.availableStaking).toEqual(0n); }); it('should get correct vesting overview data with mixed objects', () => { @@ -224,11 +239,12 @@ describe('vesting overview', () => { mixedObjects, MOCKED_CURRENT_EPOCH_TIMESTAMP, )!; - const totalAmount = + const totalAmount = bigIntRound( (SUPPLY_INCREASE_STAKER_VESTING_DURATION * SUPPLY_INCREASE_VESTING_PAYOUTS_IN_1_YEAR * lastPayout.amount) / - 0.9; + 0.9, + ); const vestingOverview = getVestingOverview(mixedObjects, Date.now()); expect(vestingOverview.totalVested).toEqual(totalAmount); @@ -243,16 +259,18 @@ describe('vesting overview', () => { const lockedAmount = vestingPortfolio.reduce( (acc, current) => - current.expirationTimestampMs > Date.now() ? acc + current.amount : acc, - 0, + current.expirationTimestampMs > Date.now() + ? acc + bigIntRound(current.amount) + : acc, + 0n, ); expect(vestingOverview.totalLocked).toEqual(lockedAmount); expect(vestingOverview.totalUnlocked).toEqual(totalAmount - lockedAmount); const totalStaked = extendedTimelockedStakedObjects.reduce( - (acc, current) => acc + Number(current.principal), - 0, + (acc, current) => acc + bigIntRound(Number(current.principal)), + 0n, ); expect(vestingOverview.totalStaked).toEqual(totalStaked); @@ -260,13 +278,17 @@ describe('vesting overview', () => { const timelockObjects = mixedObjects.filter(isTimelockedObject); const availableClaiming = timelockObjects.reduce( (acc, current) => - current.expirationTimestampMs <= Date.now() ? acc + current.locked.value : acc, - 0, + current.expirationTimestampMs <= Date.now() + ? acc + bigIntRound(current.locked.value) + : acc, + 0n, ); const availableStaking = timelockObjects.reduce( (acc, current) => - current.expirationTimestampMs > Date.now() ? acc + current.locked.value : acc, - 0, + current.expirationTimestampMs > Date.now() + ? acc + bigIntRound(current.locked.value) + : acc, + 0n, ); expect(vestingOverview.availableClaiming).toEqual(availableClaiming); expect(vestingOverview.availableStaking).toEqual(availableStaking); diff --git a/apps/wallet-dashboard/lib/utils/vesting/vesting.ts b/apps/wallet-dashboard/lib/utils/vesting/vesting.ts index c4bd743b5ef..e0bb8a29158 100644 --- a/apps/wallet-dashboard/lib/utils/vesting/vesting.ts +++ b/apps/wallet-dashboard/lib/utils/vesting/vesting.ts @@ -149,52 +149,61 @@ export function getVestingOverview( if (vestingObjects.length === 0 || !latestPayout) { return { - totalVested: 0, - totalUnlocked: 0, - totalLocked: 0, - totalStaked: 0, - availableClaiming: 0, - availableStaking: 0, + totalVested: 0n, + totalUnlocked: 0n, + totalLocked: 0n, + totalStaked: 0n, + totalEarned: 0n, + availableClaiming: 0n, + availableStaking: 0n, }; } const userType = getSupplyIncreaseVestingUserType([latestPayout]); const vestingPayoutsCount = getSupplyIncreaseVestingPayoutsCount(userType!); // Note: we add the initial payout to the total rewards, 10% of the total rewards are paid out immediately - const totalVestedAmount = (vestingPayoutsCount * latestPayout.amount) / 0.9; + const totalVestedAmount = BigInt(Math.floor((vestingPayoutsCount * latestPayout.amount) / 0.9)); const vestingPortfolio = buildSupplyIncreaseVestingSchedule( latestPayout, currentEpochTimestamp, ); const totalLockedAmount = vestingPortfolio.reduce( (acc, current) => - current.expirationTimestampMs > currentEpochTimestamp ? acc + current.amount : acc, - 0, + current.expirationTimestampMs > currentEpochTimestamp + ? acc + BigInt(current.amount) + : acc, + 0n, ); const totalUnlockedVestedAmount = totalVestedAmount - totalLockedAmount; const timelockedStakedObjects = vestingObjects.filter(isTimelockedStakedIota); const totalStaked = timelockedStakedObjects.reduce( - (acc, current) => acc + Number(current.principal), - 0, + (acc, current) => acc + BigInt(current.principal), + 0n, ); + const totalEarned = timelockedStakedObjects + .filter((t) => t.status === 'Active') + .reduce((acc, current) => { + return acc + BigInt(current.estimatedReward); + }, 0n); + const timelockedObjects = vestingObjects.filter(isTimelockedObject); const totalAvailableClaimingAmount = timelockedObjects.reduce( (acc, current) => current.expirationTimestampMs <= currentEpochTimestamp - ? acc + current.locked.value + ? acc + BigInt(current.locked.value) : acc, - 0, + 0n, ); const totalAvailableStakingAmount = timelockedObjects.reduce( (acc, current) => current.expirationTimestampMs > currentEpochTimestamp && current.locked.value >= MIN_STAKING_THRESHOLD - ? acc + current.locked.value + ? acc + BigInt(current.locked.value) : acc, - 0, + 0n, ); return { @@ -202,6 +211,7 @@ export function getVestingOverview( totalUnlocked: totalUnlockedVestedAmount, totalLocked: totalLockedAmount, totalStaked: totalStaked, + totalEarned: totalEarned, availableClaiming: totalAvailableClaimingAmount, availableStaking: totalAvailableStakingAmount, };