diff --git a/apps/core/src/hooks/stake/index.ts b/apps/core/src/hooks/stake/index.ts index 53e1fc44806..f422d9d1f61 100644 --- a/apps/core/src/hooks/stake/index.ts +++ b/apps/core/src/hooks/stake/index.ts @@ -4,3 +4,4 @@ export * from './useGetDelegatedStake'; export * from './useTotalDelegatedRewards'; export * from './useTotalDelegatedStake'; +export * from './useValidatorInfo'; diff --git a/apps/wallet-dashboard/hooks/useValidatorInfo.tsx b/apps/core/src/hooks/stake/useValidatorInfo.tsx similarity index 82% rename from apps/wallet-dashboard/hooks/useValidatorInfo.tsx rename to apps/core/src/hooks/stake/useValidatorInfo.tsx index 214dbe20e0a..3b8ec1bacf7 100644 --- a/apps/wallet-dashboard/hooks/useValidatorInfo.tsx +++ b/apps/core/src/hooks/stake/useValidatorInfo.tsx @@ -2,14 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { useMemo } from 'react'; import { useIotaClientQuery } from '@iota/dapp-kit'; -import { useGetValidatorsApy } from '@iota/core'; +import { useGetValidatorsApy } from '../'; -export function useValidatorInfo(validatorAddress: string) { +export function useValidatorInfo({ validatorAddress }: { validatorAddress: string }) { const { data: system } = useIotaClientQuery('getLatestIotaSystemState'); const { data: rollingAverageApys } = useGetValidatorsApy(); - const currentEpoch = Number(system?.epoch || 0); - const validatorSummary = useMemo(() => { if (!system) return null; @@ -20,6 +18,11 @@ export function useValidatorInfo(validatorAddress: string) { ); }, [validatorAddress, system]); + const currentEpoch = Number(system?.epoch || 0); + + //TODO: verify this is the correct validator stake balance + const totalValidatorStake = validatorSummary?.stakingPoolIotaBalance || 0; + const stakingPoolActivationEpoch = Number(validatorSummary?.stakingPoolActivationEpoch || 0); // flag as new validator if the validator was activated in the last epoch @@ -34,6 +37,7 @@ export function useValidatorInfo(validatorAddress: string) { }; return { + system, validatorSummary, name: validatorSummary?.name || '', stakingPoolActivationEpoch, @@ -42,5 +46,6 @@ export function useValidatorInfo(validatorAddress: string) { isAtRisk, apy, isApyApproxZero, + totalValidatorStake, }; } diff --git a/apps/wallet-dashboard/app/(protected)/staking/page.tsx b/apps/wallet-dashboard/app/(protected)/staking/page.tsx index 2a64ae657e4..3d5d03a5d15 100644 --- a/apps/wallet-dashboard/app/(protected)/staking/page.tsx +++ b/apps/wallet-dashboard/app/(protected)/staking/page.tsx @@ -21,7 +21,7 @@ import { useState } from 'react'; function StakingDashboardPage(): JSX.Element { const account = useCurrentAccount(); - const [dialogStakeView, setDialogStakeView] = useState(); + const [stakeDialogView, setStakeDialogView] = useState(); const [selectedStake, setSelectedStake] = useState(null); const { data: delegatedStakeData } = useGetDelegatedStake({ address: account?.address || '', @@ -42,21 +42,21 @@ function StakingDashboardPage(): JSX.Element { ); const viewStakeDetails = (extendedStake: ExtendedDelegatedStake) => { - setDialogStakeView(StakeDialogView.Details); + setStakeDialogView(StakeDialogView.Details); setSelectedStake(extendedStake); }; function handleCloseStakeDialog() { setSelectedStake(null); - setDialogStakeView(undefined); + setStakeDialogView(undefined); } function handleNewStake() { setSelectedStake(null); - setDialogStakeView(undefined); + setStakeDialogView(StakeDialogView.SelectValidator); } - const isDialogStakeOpen = dialogStakeView !== undefined; + const isDialogStakeOpen = stakeDialogView !== undefined; return ( <> @@ -92,8 +92,8 @@ function StakingDashboardPage(): JSX.Element { stakedDetails={selectedStake} isOpen={isDialogStakeOpen} handleClose={handleCloseStakeDialog} - view={dialogStakeView} - setView={setDialogStakeView} + view={stakeDialogView} + setView={setStakeDialogView} /> )} diff --git a/apps/wallet-dashboard/components/Dialogs/StakeDetails/StakeDetailsDialog.tsx b/apps/wallet-dashboard/components/Dialogs/StakeDetails/StakeDetailsDialog.tsx deleted file mode 100644 index 3d96393d4bc..00000000000 --- a/apps/wallet-dashboard/components/Dialogs/StakeDetails/StakeDetailsDialog.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2024 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import { DialogView } from '@/lib/interfaces'; -import { StakeDialogView } from './views'; -import { useState } from 'react'; -import { ExtendedDelegatedStake } from '@iota/core'; -import { Dialog, DialogBody, DialogContent, DialogPosition, Header } from '@iota/apps-ui-kit'; -import { UnstakeDialogView } from '../Unstake'; - -enum DialogViewIdentifier { - StakeDetails = 'StakeDetails', - Unstake = 'Unstake', -} - -interface StakeDetailsProps { - extendedStake: ExtendedDelegatedStake; - showActiveStatus?: boolean; - handleClose: () => void; -} - -export function StakeDetailsDialog({ - extendedStake, - showActiveStatus, - handleClose, -}: StakeDetailsProps) { - const [open, setOpen] = useState(true); - const [currentViewId, setCurrentViewId] = useState( - DialogViewIdentifier.StakeDetails, - ); - - const VIEWS: Record = { - [DialogViewIdentifier.StakeDetails]: { - header:
, - body: ( - setCurrentViewId(DialogViewIdentifier.Unstake)} - /> - ), - }, - [DialogViewIdentifier.Unstake]: { - header:
, - body: ( - - ), - }, - }; - - const currentView = VIEWS[currentViewId]; - - return ( - { - if (!open) { - handleClose(); - } - setOpen(open); - }} - > - - {currentView.header} -
- {currentView.body} -
-
-
- ); -} diff --git a/apps/wallet-dashboard/components/Dialogs/StakeDetails/index.ts b/apps/wallet-dashboard/components/Dialogs/StakeDetails/index.ts deleted file mode 100644 index be500ac73c8..00000000000 --- a/apps/wallet-dashboard/components/Dialogs/StakeDetails/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) 2024 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -export * from './StakeDetailsDialog'; diff --git a/apps/wallet-dashboard/components/Dialogs/StakeDetails/views/StakeDetailsView.tsx b/apps/wallet-dashboard/components/Dialogs/StakeDetails/views/StakeDetailsView.tsx deleted file mode 100644 index 8ab7ef3ac75..00000000000 --- a/apps/wallet-dashboard/components/Dialogs/StakeDetails/views/StakeDetailsView.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2024 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -import React from 'react'; -import { Button } from '@/components'; -import { ExtendedDelegatedStake } from '@iota/core'; - -interface StakeDialogProps { - extendedStake: ExtendedDelegatedStake; - onUnstake: () => void; -} - -export function StakeDialogView({ extendedStake, onUnstake }: StakeDialogProps): JSX.Element { - return ( - <> -
-
-

Stake ID: {extendedStake.stakedIotaId}

-

Validator: {extendedStake.validatorAddress}

-

Stake: {extendedStake.principal}

-

Stake Active Epoch: {extendedStake.stakeActiveEpoch}

-

Stake Request Epoch: {extendedStake.stakeRequestEpoch}

- {extendedStake.status === 'Active' && ( -

Estimated reward: {extendedStake.estimatedReward}

- )} -

Status: {extendedStake.status}

-
-
-
- - -
- - ); -} diff --git a/apps/wallet-dashboard/components/Dialogs/StakeDetails/views/index.ts b/apps/wallet-dashboard/components/Dialogs/StakeDetails/views/index.ts deleted file mode 100644 index 145902b67d8..00000000000 --- a/apps/wallet-dashboard/components/Dialogs/StakeDetails/views/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) 2024 IOTA Stiftung -// SPDX-License-Identifier: Apache-2.0 - -export * from './StakeDetailsView'; diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx index be8331b865f..faf7487005d 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx +++ b/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx @@ -2,26 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useState } from 'react'; -import { EnterAmountView, SelectValidatorView, DetailsView } from './views'; +import { EnterAmountView, SelectValidatorView } from './views'; import { useNotifications, useNewStakeTransaction, useGetCurrentEpochStartTimestamp, } from '@/hooks'; import { + ExtendedDelegatedStake, GroupedTimelockObject, parseAmount, TIMELOCK_IOTA_TYPE, useCoinMetadata, useGetAllOwnedObjects, useGetValidatorsApy, - ExtendedDelegatedStake, } from '@iota/core'; import { useCurrentAccount, useSignAndExecuteTransaction } from '@iota/dapp-kit'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; import { NotificationType } from '@/stores/notificationStore'; import { prepareObjectsForTimelockedStakingTransaction } from '@/lib/utils'; import { Dialog } from '@iota/apps-ui-kit'; +import { DetailsView, UnstakeView } from './views'; export enum StakeDialogView { Details, @@ -35,16 +36,16 @@ interface StakeDialogProps { onSuccess?: (digest: string) => void; isOpen: boolean; handleClose: () => void; - stakedDetails?: ExtendedDelegatedStake | null; view: StakeDialogView; - setView: (nextView: StakeDialogView) => void; + setView: (view: StakeDialogView) => void; + stakedDetails?: ExtendedDelegatedStake | null; } function StakeDialog({ onSuccess, isTimelockedStaking, isOpen, - handleClose: handleClose, + handleClose, view, setView, stakedDetails, @@ -93,6 +94,20 @@ function StakeDialog({ setSelectedValidator(validator); } + function selectValidatorHandleNext(): void { + if (selectedValidator) { + setView(StakeDialogView.EnterAmount); + } + } + + function detailsHandleUnstake() { + setView(StakeDialogView.Unstake); + } + + function detailsHandleStake() { + setView(StakeDialogView.SelectValidator); + } + function handleStake(): void { if (isTimelockedStaking && groupedTimelockObjects.length === 0) { addNotification('Invalid stake amount. Please try again.', NotificationType.Error); @@ -122,18 +137,6 @@ function StakeDialog({ }); } - function detailsHandleUnstake() { - setView(StakeDialogView.Unstake); - } - - function detailsHandleStake() { - setView(StakeDialogView.SelectValidator); - } - - function selectValidatorHandleNext() { - setView(StakeDialogView.EnterAmount); - } - return ( handleClose()}> {view === StakeDialogView.Details && stakedDetails && ( @@ -157,10 +160,17 @@ function StakeDialog({ setAmount(e.target.value)} onBack={handleBack} onStake={handleStake} - isStakeDisabled={!amount} + /> + )} + {view === StakeDialogView.Unstake && stakedDetails && ( + )} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/StakedDetailsDialog.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/StakedDetailsDialog.tsx new file mode 100644 index 00000000000..4f32601a2dc --- /dev/null +++ b/apps/wallet-dashboard/components/Dialogs/Staking/StakedDetailsDialog.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { + ExtendedDelegatedStake, + formatPercentageDisplay, + ImageIcon, + ImageIconSize, + useFormatCoin, + useValidatorInfo, +} from '@iota/core'; +import { + Badge, + BadgeType, + Button, + ButtonType, + Card, + CardBody, + CardImage, + CardType, + Dialog, + DialogBody, + DialogContent, + DialogPosition, + Divider, + Header, + InfoBox, + InfoBoxStyle, + InfoBoxType, + KeyValueInfo, + LoadingIndicator, + Panel, +} from '@iota/apps-ui-kit'; +import { Warning } from '@iota/ui-icons'; +import { useUnstakeTransaction } from '@/hooks'; +import { + useCurrentAccount, + useIotaClientQuery, + useSignAndExecuteTransaction, +} from '@iota/dapp-kit'; +import { formatAddress, IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; + +interface StakeDialogProps { + stakedDetails: ExtendedDelegatedStake; + showActiveStatus?: boolean; + handleClose: () => void; +} + +export function StakedDetailsDialog({ + handleClose, + stakedDetails, + showActiveStatus, +}: StakeDialogProps): JSX.Element { + const account = useCurrentAccount(); + const totalStake = BigInt(stakedDetails?.principal || 0n); + const validatorAddress = stakedDetails?.validatorAddress; + const { isPending: loadingValidators, isError: errorValidators } = useIotaClientQuery( + 'getLatestIotaSystemState', + ); + const iotaEarned = BigInt(stakedDetails?.estimatedReward || 0n); + const [iotaEarnedFormatted, iotaEarnedSymbol] = useFormatCoin(iotaEarned, IOTA_TYPE_ARG); + const [totalStakeFormatted, totalStakeSymbol] = useFormatCoin(totalStake, IOTA_TYPE_ARG); + + const { name, commission, newValidator, isAtRisk, apy, isApyApproxZero } = useValidatorInfo({ + validatorAddress: validatorAddress, + }); + + const { data: unstakeData } = useUnstakeTransaction( + stakedDetails.stakedIotaId, + account?.address || '', + ); + const { mutateAsync: signAndExecuteTransaction } = useSignAndExecuteTransaction(); + + const subtitle = showActiveStatus ? ( +
+ {formatAddress(validatorAddress)} + {newValidator && } + {isAtRisk && } +
+ ) : ( + formatAddress(validatorAddress) + ); + + async function handleUnstake(): Promise { + if (!unstakeData) return; + await signAndExecuteTransaction({ + transaction: unstakeData.transaction, + }); + } + + function handleAddNewStake() { + // pass + } + + if (loadingValidators) { + return ( +
+ +
+ ); + } + + if (errorValidators) { + return ( +
+ } + /> +
+ ); + } + + return ( + + +
+
+
+ +
+ + + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/wallet-dashboard/components/Dialogs/Unstake/index.ts b/apps/wallet-dashboard/components/Dialogs/Staking/hooks/index.ts similarity index 68% rename from apps/wallet-dashboard/components/Dialogs/Unstake/index.ts rename to apps/wallet-dashboard/components/Dialogs/Staking/hooks/index.ts index 4a9444c3b5b..9f0940ce152 100644 --- a/apps/wallet-dashboard/components/Dialogs/Unstake/index.ts +++ b/apps/wallet-dashboard/components/Dialogs/Staking/hooks/index.ts @@ -1,4 +1,4 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -export * from './views'; +export * from './useStakeTxsInfo'; diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/hooks/useStakeTxsInfo.ts b/apps/wallet-dashboard/components/Dialogs/Staking/hooks/useStakeTxsInfo.ts new file mode 100644 index 00000000000..86e3fe7cc8e --- /dev/null +++ b/apps/wallet-dashboard/components/Dialogs/Staking/hooks/useStakeTxsInfo.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 +import { useGetTimeBeforeEpochNumber, useTimeAgo, TimeUnit } from '@iota/core'; + +export const NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_REDEEMABLE = 2; +export const NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_STARTS = 1; + +export function useStakeTxnInfo(startEpoch?: string | number) { + const startEarningRewardsEpoch = + Number(startEpoch || 0) + NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_STARTS; + + const redeemableRewardsEpoch = + Number(startEpoch || 0) + NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_REDEEMABLE; + + const { data: timeBeforeStakeRewardsStarts } = + useGetTimeBeforeEpochNumber(startEarningRewardsEpoch); + const timeBeforeStakeRewardsStartsAgo = useTimeAgo({ + timeFrom: timeBeforeStakeRewardsStarts, + shortedTimeLabel: false, + shouldEnd: true, + maxTimeUnit: TimeUnit.ONE_HOUR, + }); + const stakedRewardsStartEpoch = + timeBeforeStakeRewardsStarts > 0 + ? `${timeBeforeStakeRewardsStartsAgo === '--' ? '' : 'in'} ${timeBeforeStakeRewardsStartsAgo}` + : startEpoch + ? `Epoch #${Number(startEarningRewardsEpoch)}` + : '--'; + + const { data: timeBeforeStakeRewardsRedeemable } = + useGetTimeBeforeEpochNumber(redeemableRewardsEpoch); + const timeBeforeStakeRewardsRedeemableAgo = useTimeAgo({ + timeFrom: timeBeforeStakeRewardsRedeemable, + shortedTimeLabel: false, + shouldEnd: true, + maxTimeUnit: TimeUnit.ONE_HOUR, + }); + const timeBeforeStakeRewardsRedeemableAgoDisplay = + timeBeforeStakeRewardsRedeemable > 0 + ? `${timeBeforeStakeRewardsRedeemableAgo === '--' ? '' : 'in'} ${timeBeforeStakeRewardsRedeemableAgo}` + : startEpoch + ? `Epoch #${Number(redeemableRewardsEpoch)}` + : '--'; + + return { + stakedRewardsStartEpoch, + timeBeforeStakeRewardsRedeemableAgoDisplay, + }; +} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/index.ts b/apps/wallet-dashboard/components/Dialogs/Staking/index.ts index 1e5ad764bbc..e415159b7c5 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/index.ts +++ b/apps/wallet-dashboard/components/Dialogs/Staking/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export { default as StakeDialog } from './StakeDialog'; +export * from './StakedDetailsDialog'; diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx index 98f61fe2cac..6451fa98644 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx @@ -2,41 +2,125 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Button, Input } from '@/components'; +import { useFormatCoin, useBalance, CoinFormat } from '@iota/core'; +import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; +import { + Button, + ButtonType, + KeyValueInfo, + Panel, + Divider, + Input, + InputType, + Header, +} from '@iota/apps-ui-kit'; +import { useStakeTxnInfo } from '../hooks'; +import { useCurrentAccount, useIotaClientQuery } from '@iota/dapp-kit'; +import { Validator } from './Validator'; +import { StakedInfo } from './StakedInfo'; +import { Layout, LayoutBody, LayoutFooter } from './Layout'; interface EnterAmountViewProps { - selectedValidator: string | null; + selectedValidator: string; amount: string; onChange: (e: React.ChangeEvent) => void; onBack: () => void; onStake: () => void; - isStakeDisabled: boolean; + showActiveStatus?: boolean; + gasBudget?: string | number | null; + handleClose: () => void; } function EnterAmountView({ - selectedValidator, + selectedValidator: selectedValidatorAddress, amount, onChange, onBack, onStake, - isStakeDisabled, + gasBudget = 0, + handleClose, }: EnterAmountViewProps): JSX.Element { + const account = useCurrentAccount(); + const accountAddress = account?.address; + + const { data: system } = useIotaClientQuery('getLatestIotaSystemState'); + const { data: iotaBalance } = useBalance(accountAddress!); + + const coinBalance = BigInt(iotaBalance?.totalBalance || 0); + const maxTokenBalance = coinBalance - BigInt(Number(gasBudget)); + const [maxTokenFormatted, maxTokenFormattedSymbol] = useFormatCoin( + maxTokenBalance, + IOTA_TYPE_ARG, + CoinFormat.FULL, + ); + const [gas, symbol] = useFormatCoin(gasBudget, IOTA_TYPE_ARG); + const { stakedRewardsStartEpoch, timeBeforeStakeRewardsRedeemableAgoDisplay } = useStakeTxnInfo( + system?.epoch, + ); + return ( -
-

Selected Validator: {selectedValidator}

- -
- - -
-
+ +
+ +
+
+ + +
+ +
+ + +
+ + + + +
+
+
+
+
+ +
+
+
+ ); } diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/SelectValidatorView.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/SelectValidatorView.tsx index 73e25ef25d1..f04641ee1df 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/views/SelectValidatorView.tsx +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/SelectValidatorView.tsx @@ -2,109 +2,55 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { ImageIcon, ImageIconSize, formatPercentageDisplay } from '@iota/core'; -import { - Header, - Button, - Card, - CardBody, - CardImage, - CardAction, - CardActionType, - CardType, - Badge, - BadgeType, -} from '@iota/apps-ui-kit'; -import { formatAddress } from '@iota/iota-sdk/utils'; -import { useValidatorInfo } from '@/hooks'; -import { Layout, LayoutFooter, LayoutBody } from './Layout'; +import { Button, Header } from '@iota/apps-ui-kit'; + +import { Validator } from './Validator'; +import { Layout, LayoutBody, LayoutFooter } from './Layout'; + interface SelectValidatorViewProps { validators: string[]; onSelect: (validator: string) => void; - handleClose: () => void; onNext: () => void; selectedValidator: string; + handleClose: () => void; } function SelectValidatorView({ validators, onSelect, - handleClose, onNext, selectedValidator, + handleClose, }: SelectValidatorViewProps): JSX.Element { return ( -
+
- {validators.map((validator) => ( - - ))} +
+ {validators.map((validator) => ( + + ))} +
- - {!!selectedValidator && ( + {!!selectedValidator && ( +