diff --git a/apps/core/src/components/coin/CoinIcon.tsx b/apps/core/src/components/coin/CoinIcon.tsx index f93a12baf64..1ced5642085 100644 --- a/apps/core/src/components/coin/CoinIcon.tsx +++ b/apps/core/src/components/coin/CoinIcon.tsx @@ -3,10 +3,9 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { useCoinMetadata } from '../../hooks'; +import { useCoinMetadata, ImageIcon, ImageIconSize } from '../../'; import { IotaLogoMark } from '@iota/ui-icons'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; -import { ImageIcon, ImageIconSize } from '../icon'; import cx from 'clsx'; interface NonIotaCoinProps { diff --git a/apps/core/src/constants/staking.constants.ts b/apps/core/src/constants/staking.constants.ts index e79939eee60..5c1a2ea2b7e 100644 --- a/apps/core/src/constants/staking.constants.ts +++ b/apps/core/src/constants/staking.constants.ts @@ -6,3 +6,6 @@ export const UNSTAKING_REQUEST_EVENT = '0x3::validator::UnstakingRequestEvent'; export const DELEGATED_STAKES_QUERY_STALE_TIME = 10_000; export const DELEGATED_STAKES_QUERY_REFETCH_INTERVAL = 30_000; + +export const NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_REDEEMABLE = 2; +export const NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_STARTS = 1; diff --git a/apps/core/src/hooks/stake/index.ts b/apps/core/src/hooks/stake/index.ts index 53e1fc44806..2477e671351 100644 --- a/apps/core/src/hooks/stake/index.ts +++ b/apps/core/src/hooks/stake/index.ts @@ -4,3 +4,5 @@ export * from './useGetDelegatedStake'; export * from './useTotalDelegatedRewards'; export * from './useTotalDelegatedStake'; +export * from './useValidatorInfo'; +export * from './useStakeTxnInfo'; diff --git a/apps/core/src/hooks/stake/useStakeTxnInfo.ts b/apps/core/src/hooks/stake/useStakeTxnInfo.ts new file mode 100644 index 00000000000..a1ea1fcd53a --- /dev/null +++ b/apps/core/src/hooks/stake/useStakeTxnInfo.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 +import { + useGetTimeBeforeEpochNumber, + useTimeAgo, + TimeUnit, + NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_REDEEMABLE, + NUM_OF_EPOCH_BEFORE_STAKING_REWARDS_STARTS, +} from '../../'; + +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 + ? `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 + ? `in ${timeBeforeStakeRewardsRedeemableAgo}` + : startEpoch + ? `Epoch #${Number(redeemableRewardsEpoch)}` + : '--'; + + return { + stakedRewardsStartEpoch, + timeBeforeStakeRewardsRedeemableAgoDisplay, + }; +} diff --git a/apps/core/src/hooks/stake/useValidatorInfo.tsx b/apps/core/src/hooks/stake/useValidatorInfo.tsx new file mode 100644 index 00000000000..2ae4d23c933 --- /dev/null +++ b/apps/core/src/hooks/stake/useValidatorInfo.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 +import { useIotaClientQuery } from '@iota/dapp-kit'; +import { useGetValidatorsApy } from '../'; + +export function useValidatorInfo({ validatorAddress }: { validatorAddress: string }) { + const { + data: system, + isPending: isPendingValidators, + isError: errorValidators, + } = useIotaClientQuery('getLatestIotaSystemState'); + const { data: rollingAverageApys } = useGetValidatorsApy(); + + const validatorSummary = + system?.activeValidators.find((validator) => validator.iotaAddress === validatorAddress) || + null; + + const currentEpoch = Number(system?.epoch || 0); + + const stakingPoolActivationEpoch = Number(validatorSummary?.stakingPoolActivationEpoch || 0); + + // flag as new validator if the validator was activated in the last epoch + // for genesis validators, this will be false + const newValidator = currentEpoch - stakingPoolActivationEpoch <= 1 && currentEpoch !== 0; + + // flag if the validator is at risk of being removed from the active set + const isAtRisk = system?.atRiskValidators.some((item) => item[0] === validatorAddress); + + const { apy, isApyApproxZero } = rollingAverageApys?.[validatorAddress] ?? { + apy: null, + }; + + const commission = validatorSummary ? Number(validatorSummary.commissionRate) / 100 : 0; + + return { + system, + isPendingValidators, + errorValidators, + + currentEpoch, + validatorSummary, + name: validatorSummary?.name || '', + stakingPoolActivationEpoch, + commission, + newValidator, + isAtRisk, + apy, + isApyApproxZero, + }; +} diff --git a/apps/core/src/hooks/useGetStakingValidatorDetails.ts b/apps/core/src/hooks/useGetStakingValidatorDetails.ts index 70a6fe36b74..f77ee897e53 100644 --- a/apps/core/src/hooks/useGetStakingValidatorDetails.ts +++ b/apps/core/src/hooks/useGetStakingValidatorDetails.ts @@ -75,6 +75,7 @@ export function useGetStakingValidatorDetails({ return { epoch: Number(system?.epoch) || 0, totalStake: totalStakeFormatted, + totalStakeOriginal: totalStake, totalValidatorsStake: totalValidatorsStakeFormatted, totalStakePercentage, validatorApy, diff --git a/apps/wallet/src/ui/app/staking/stake/utils/validation.ts b/apps/core/src/utils/stake/createValidationSchema.ts similarity index 97% rename from apps/wallet/src/ui/app/staking/stake/utils/validation.ts rename to apps/core/src/utils/stake/createValidationSchema.ts index 198efb8a941..b051585f943 100644 --- a/apps/wallet/src/ui/app/staking/stake/utils/validation.ts +++ b/apps/core/src/utils/stake/createValidationSchema.ts @@ -2,7 +2,7 @@ // Modifications Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import { CoinFormat, formatBalance } from '@iota/core'; +import { CoinFormat, formatBalance } from '../../'; import BigNumber from 'bignumber.js'; import { mixed, object } from 'yup'; diff --git a/apps/core/src/utils/stake/index.ts b/apps/core/src/utils/stake/index.ts index 5f7d694c839..6ecca7353f6 100644 --- a/apps/core/src/utils/stake/index.ts +++ b/apps/core/src/utils/stake/index.ts @@ -6,3 +6,4 @@ export * from './formatDelegatedStake'; export * from './createStakeTransaction'; export * from './createTimelockedUnstakeTransaction'; export * from './createTimelockedStakeTransaction'; +export * from './createValidationSchema'; diff --git a/apps/wallet-dashboard/app/(protected)/staking/page.tsx b/apps/wallet-dashboard/app/(protected)/staking/page.tsx index 771cc4f79a5..67978b8451f 100644 --- a/apps/wallet-dashboard/app/(protected)/staking/page.tsx +++ b/apps/wallet-dashboard/app/(protected)/staking/page.tsx @@ -4,7 +4,8 @@ 'use client'; import { AmountBox, Box, StakeCard, StakeDialog, Button } from '@/components'; -import { StakeDetailsDialog } from '@/components/Dialogs'; +import { useStakeDialog } from '@/components/Dialogs/Staking/hooks/useStakeDialog'; + import { ExtendedDelegatedStake, formatDelegatedStake, @@ -17,12 +18,22 @@ import { } from '@iota/core'; import { useCurrentAccount } from '@iota/dapp-kit'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; -import { useState } from 'react'; +import { StakeDialogView } from '@/components/Dialogs/Staking/StakeDialog'; function StakingDashboardPage(): JSX.Element { const account = useCurrentAccount(); - const [isDialogStakeOpen, setIsDialogStakeOpen] = useState(false); - const [selectedStake, setSelectedStake] = useState(null); + + const { + isDialogStakeOpen, + stakeDialogView, + setStakeDialogView, + selectedStake, + setSelectedStake, + selectedValidator, + setSelectedValidator, + handleCloseStakeDialog, + handleNewStake, + } = useStakeDialog(); const { data: delegatedStakeData } = useGetDelegatedStake({ address: account?.address || '', @@ -43,13 +54,10 @@ function StakingDashboardPage(): JSX.Element { ); const viewStakeDetails = (extendedStake: ExtendedDelegatedStake) => { + setStakeDialogView(StakeDialogView.Details); setSelectedStake(extendedStake); }; - function handleNewStake() { - setIsDialogStakeOpen(true); - } - return ( <>
@@ -79,12 +87,15 @@ function StakingDashboardPage(): JSX.Element {
- ; - {selectedStake && ( - setSelectedStake(null)} - showActiveStatus + {isDialogStakeOpen && stakeDialogView && ( + )} 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 d7e9d1cbc75..6007798119b 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx +++ b/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -import React, { useState } from 'react'; +import React, { useMemo } from 'react'; import { EnterAmountView, SelectValidatorView } from './views'; import { useNotifications, @@ -9,45 +9,93 @@ import { useGetCurrentEpochStartTimestamp, } from '@/hooks'; import { + ExtendedDelegatedStake, GroupedTimelockObject, parseAmount, TIMELOCK_IOTA_TYPE, useCoinMetadata, useGetAllOwnedObjects, useGetValidatorsApy, + useBalance, + createValidationSchema, } from '@iota/core'; +import { FormikProvider, useFormik } from 'formik'; +import type { FormikHelpers } from 'formik'; 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, DialogBody, DialogContent, DialogPosition, Header } from '@iota/apps-ui-kit'; +import { Dialog } from '@iota/apps-ui-kit'; +import { DetailsView, UnstakeView } from './views'; +import { FormValues } from './views/EnterAmountView'; + +export const MIN_NUMBER_IOTA_TO_STAKE = 1; + +export enum StakeDialogView { + Details = 'Details', + SelectValidator = 'SelectValidator', + EnterAmount = 'EnterAmount', + Unstake = 'Unstake', +} + +const INITIAL_VALUES = { + amount: '', +}; interface StakeDialogProps { isTimelockedStaking?: boolean; onSuccess?: (digest: string) => void; isOpen: boolean; - setOpen: (bool: boolean) => void; -} + handleClose: () => void; + view: StakeDialogView; + setView?: (view: StakeDialogView) => void; + stakedDetails?: ExtendedDelegatedStake | null; -enum Step { - SelectValidator, - EnterAmount, + selectedValidator?: string; + setSelectedValidator?: (validator: string) => void; } -function StakeDialog({ +export function StakeDialog({ onSuccess, isTimelockedStaking, isOpen, - setOpen, + handleClose, + view, + setView, + stakedDetails, + selectedValidator = '', + setSelectedValidator, }: StakeDialogProps): JSX.Element { - const [step, setStep] = useState(Step.SelectValidator); - const [selectedValidator, setSelectedValidator] = useState(''); - const [amount, setAmount] = useState(''); const account = useCurrentAccount(); const senderAddress = account?.address ?? ''; + const { data: iotaBalance } = useBalance(senderAddress!); + const coinBalance = BigInt(iotaBalance?.totalBalance || 0); const { data: metadata } = useCoinMetadata(IOTA_TYPE_ARG); const coinDecimals = metadata?.decimals ?? 0; + const coinSymbol = metadata?.symbol ?? ''; + const minimumStake = parseAmount(MIN_NUMBER_IOTA_TO_STAKE.toString(), coinDecimals); + + const validationSchema = useMemo( + () => + createValidationSchema( + coinBalance, + coinSymbol, + coinDecimals, + view === StakeDialogView.Unstake, + minimumStake, + ), + [coinBalance, coinSymbol, coinDecimals, view, minimumStake], + ); + + const formik = useFormik({ + initialValues: INITIAL_VALUES, + validationSchema: validationSchema, + onSubmit: onSubmit, + validateOnMount: true, + }); + + const amount = formik.values.amount || `${MIN_NUMBER_IOTA_TO_STAKE}`; const amountWithoutDecimals = parseAmount(amount, coinDecimals); const { data: currentEpochMs } = useGetCurrentEpochStartTimestamp(); const { data: timelockedObjects } = useGetAllOwnedObjects(senderAddress, { @@ -63,7 +111,7 @@ function StakeDialog({ ); } - const { data: newStakeData } = useNewStakeTransaction( + const { data: newStakeData, isLoading: isTransactionLoading } = useNewStakeTransaction( selectedValidator, amountWithoutDecimals, senderAddress, @@ -77,17 +125,26 @@ function StakeDialog({ const validators = Object.keys(rollingAverageApys ?? {}) ?? []; - function handleNext(): void { - setStep(Step.EnterAmount); - } - function handleBack(): void { - setStep(Step.SelectValidator); + setView?.(StakeDialogView.SelectValidator); } function handleValidatorSelect(validator: string): void { - setSelectedValidator(validator); - handleNext(); + setSelectedValidator?.(validator); + } + + function selectValidatorHandleNext(): void { + if (selectedValidator) { + setView?.(StakeDialogView.EnterAmount); + } + } + + function detailsHandleUnstake() { + setView?.(StakeDialogView.Unstake); + } + + function detailsHandleStake() { + setView?.(StakeDialogView.SelectValidator); } function handleStake(): void { @@ -112,6 +169,7 @@ function StakeDialog({ }, ) .then(() => { + handleClose(); addNotification('Stake transaction has been sent'); }) .catch(() => { @@ -119,31 +177,51 @@ function StakeDialog({ }); } + function onSubmit(_: FormValues, { resetForm }: FormikHelpers) { + handleStake(); + resetForm(); + } + return ( - - -
setOpen(false)} /> - - {step === Step.SelectValidator && ( + handleClose()}> + + <> + {view === StakeDialogView.Details && stakedDetails && ( + + )} + {view === StakeDialogView.SelectValidator && ( )} - {step === Step.EnterAmount && ( + {view === StakeDialogView.EnterAmount && ( setAmount(e.target.value)} + handleClose={handleClose} onBack={handleBack} onStake={handleStake} - isStakeDisabled={!amount} + gasBudget={newStakeData?.gasBudget} + isTransactionLoading={isTransactionLoading} /> )} - - + {view === StakeDialogView.Unstake && stakedDetails && ( + + )} + +
); } - -export default StakeDialog; diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/hooks/useStakeDialog.ts b/apps/wallet-dashboard/components/Dialogs/Staking/hooks/useStakeDialog.ts new file mode 100644 index 00000000000..f14b97db803 --- /dev/null +++ b/apps/wallet-dashboard/components/Dialogs/Staking/hooks/useStakeDialog.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { useState } from 'react'; +import { ExtendedDelegatedStake } from '@iota/core'; + +import { StakeDialogView } from '@/components/Dialogs/Staking/StakeDialog'; + +export function useStakeDialog() { + const [stakeDialogView, setStakeDialogView] = useState(); + const [selectedStake, setSelectedStake] = useState(null); + const [selectedValidator, setSelectedValidator] = useState(''); + + const isDialogStakeOpen = stakeDialogView !== undefined; + + function handleCloseStakeDialog() { + setSelectedValidator(''); + setSelectedStake(null); + setStakeDialogView(undefined); + } + + function handleNewStake() { + setSelectedStake(null); + setStakeDialogView(StakeDialogView.SelectValidator); + } + + return { + isDialogStakeOpen, + stakeDialogView, + setStakeDialogView, + selectedStake, + setSelectedStake, + selectedValidator, + setSelectedValidator, + handleCloseStakeDialog, + handleNewStake, + }; +} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/index.ts b/apps/wallet-dashboard/components/Dialogs/Staking/index.ts index 1e5ad764bbc..eb698da416e 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/index.ts +++ b/apps/wallet-dashboard/components/Dialogs/Staking/index.ts @@ -1,4 +1,4 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -export { default as StakeDialog } from './StakeDialog'; +export * from './StakeDialog'; diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/DetailsView.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/DetailsView.tsx new file mode 100644 index 00000000000..79106443924 --- /dev/null +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/DetailsView.tsx @@ -0,0 +1,166 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { + ExtendedDelegatedStake, + ImageIcon, + ImageIconSize, + useFormatCoin, + formatPercentageDisplay, + useValidatorInfo, +} from '@iota/core'; +import { + Header, + Button, + ButtonType, + Card, + CardBody, + CardImage, + CardType, + Panel, + KeyValueInfo, + Badge, + BadgeType, + Divider, + InfoBox, + InfoBoxStyle, + InfoBoxType, + LoadingIndicator, +} from '@iota/apps-ui-kit'; +import { Warning } from '@iota/ui-icons'; +import { formatAddress, IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; +import { Layout, LayoutFooter, LayoutBody } from './Layout'; + +interface StakeDialogProps { + stakedDetails: ExtendedDelegatedStake; + showActiveStatus?: boolean; + handleClose: () => void; + handleUnstake: () => void; + handleStake: () => void; +} + +export function DetailsView({ + handleClose, + handleUnstake, + handleStake, + stakedDetails, + showActiveStatus, +}: StakeDialogProps): JSX.Element { + const totalStake = BigInt(stakedDetails?.principal || 0n); + const validatorAddress = stakedDetails?.validatorAddress; + const { + isAtRisk, + isPendingValidators, + errorValidators, + validatorSummary, + apy, + isApyApproxZero, + newValidator, + commission, + } = useValidatorInfo({ + validatorAddress, + }); + + const iotaEarned = BigInt(stakedDetails?.estimatedReward || 0n); + const [iotaEarnedFormatted, iotaEarnedSymbol] = useFormatCoin(iotaEarned, IOTA_TYPE_ARG); + const [totalStakeFormatted, totalStakeSymbol] = useFormatCoin(totalStake, IOTA_TYPE_ARG); + + const validatorName = validatorSummary?.name || ''; + + const subtitle = showActiveStatus ? ( +
+ {formatAddress(validatorAddress)} + {newValidator && } + {isAtRisk && } +
+ ) : ( + formatAddress(validatorAddress) + ); + + if (isPendingValidators) { + return ( +
+ +
+ ); + } + + if (errorValidators) { + return ( +
+ } + /> +
+ ); + } + + return ( + +
+ +
+ + + + + + + +
+ + + + + +
+
+
+
+ +
+
+
+ + ); +} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx index 98f61fe2cac..5016a5e97ca 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx @@ -2,41 +2,187 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Button, Input } from '@/components'; +import { + useFormatCoin, + useBalance, + CoinFormat, + parseAmount, + useCoinMetadata, + useStakeTxnInfo, +} from '@iota/core'; +import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; +import { + Button, + ButtonType, + KeyValueInfo, + Panel, + Divider, + Input, + InputType, + Header, + InfoBoxType, + InfoBoxStyle, + InfoBox, +} from '@iota/apps-ui-kit'; +import { Field, type FieldProps, useFormikContext } from 'formik'; +import { Exclamation } from '@iota/ui-icons'; +import { useCurrentAccount, useIotaClientQuery } from '@iota/dapp-kit'; -interface EnterAmountViewProps { - selectedValidator: string | null; +import { Validator } from './Validator'; +import { StakedInfo } from './StakedInfo'; +import { Layout, LayoutBody, LayoutFooter } from './Layout'; + +export interface FormValues { amount: string; - onChange: (e: React.ChangeEvent) => void; +} + +interface EnterAmountViewProps { + selectedValidator: string; onBack: () => void; onStake: () => void; - isStakeDisabled: boolean; + showActiveStatus?: boolean; + gasBudget?: string | number | null; + handleClose: () => void; + isTransactionLoading?: boolean; } function EnterAmountView({ - selectedValidator, - amount, - onChange, + selectedValidator: selectedValidatorAddress, onBack, onStake, - isStakeDisabled, + gasBudget = 0, + handleClose, + isTransactionLoading, }: EnterAmountViewProps): JSX.Element { + const coinType = IOTA_TYPE_ARG; + const { data: metadata } = useCoinMetadata(coinType); + const decimals = metadata?.decimals ?? 0; + + const account = useCurrentAccount(); + const accountAddress = account?.address; + + const { values, errors } = useFormikContext(); + const amount = values.amount; + + const { data: system } = useIotaClientQuery('getLatestIotaSystemState'); + const { data: iotaBalance } = useBalance(accountAddress!); + const coinBalance = BigInt(iotaBalance?.totalBalance || 0); + + const gasBudgetBigInt = BigInt(gasBudget ?? 0); + const [gas, symbol] = useFormatCoin(gasBudget, IOTA_TYPE_ARG); + + const maxTokenBalance = coinBalance - gasBudgetBigInt; + const [maxTokenFormatted, maxTokenFormattedSymbol] = useFormatCoin( + maxTokenBalance, + IOTA_TYPE_ARG, + CoinFormat.FULL, + ); + + const caption = isTransactionLoading + ? '--' + : `${maxTokenFormatted} ${maxTokenFormattedSymbol} Available`; + + const { stakedRewardsStartEpoch, timeBeforeStakeRewardsRedeemableAgoDisplay } = useStakeTxnInfo( + system?.epoch, + ); + + const hasEnoughRemaingBalance = + maxTokenBalance > parseAmount(values.amount, decimals) + BigInt(2) * gasBudgetBigInt; + const shouldShowInsufficientRemainingFundsWarning = + maxTokenFormatted >= values.amount && !hasEnoughRemaingBalance; + return ( -
-

Selected Validator: {selectedValidator}

- -
- - -
-
+ +
+ +
+
+
+ +
+ +
+ + {({ + field: { onChange, ...field }, + form: { setFieldValue }, + meta, + }: FieldProps) => { + return ( + { + setFieldValue('amount', value, true); + }} + type={InputType.NumericFormat} + label="Amount" + value={amount} + suffix={` ${symbol}`} + placeholder="Enter amount to stake" + errorMessage={ + values.amount && meta.error ? meta.error : undefined + } + caption={coinBalance ? caption : ''} + /> + ); + }} + + {shouldShowInsufficientRemainingFundsWarning ? ( +
+ } + /> +
+ ) : null} +
+ + +
+ + + + +
+
+
+
+
+ +
+
+
+ ); } diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/Layout.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/Layout.tsx new file mode 100644 index 00000000000..7986ce592d0 --- /dev/null +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/Layout.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 +import { PropsWithChildren } from 'react'; +import { DialogBody, DialogContent, DialogPosition } from '@iota/apps-ui-kit'; + +export function Layout({ children }: PropsWithChildren) { + return ( + +
{children}
+
+ ); +} + +export function LayoutBody({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} + +export function LayoutFooter({ children }: PropsWithChildren) { + return
{children}
; +} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/SelectValidatorView.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/SelectValidatorView.tsx index 25dbeb276cf..f04641ee1df 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/views/SelectValidatorView.tsx +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/SelectValidatorView.tsx @@ -2,25 +2,54 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { Button } from '@/components'; +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; + onNext: () => void; + selectedValidator: string; + handleClose: () => void; } -function SelectValidatorView({ validators, onSelect }: SelectValidatorViewProps): JSX.Element { +function SelectValidatorView({ + validators, + onSelect, + onNext, + selectedValidator, + handleClose, +}: SelectValidatorViewProps): JSX.Element { return ( -
-

Select Validator

-
- {validators.map((validator) => ( - - ))} -
-
+ +
+ +
+
+ {validators.map((validator) => ( + + ))} +
+
+
+ {!!selectedValidator && ( + +