diff --git a/apps/core/src/components/coin/CoinIcon.tsx b/apps/core/src/components/coin/CoinIcon.tsx index 1ced5642085..4216751e4a6 100644 --- a/apps/core/src/components/coin/CoinIcon.tsx +++ b/apps/core/src/components/coin/CoinIcon.tsx @@ -63,7 +63,7 @@ export function CoinIconWrapper({ children, size, hasBorder }: CoinIconWrapperPr className={cx( size, hasBorder && 'border border-shader-neutral-light-8', - 'flex items-center justify-center rounded-full bg-neutral-100', + 'flex items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-10', )} > {children} diff --git a/apps/core/src/hooks/useGetValidatorsApy.ts b/apps/core/src/hooks/useGetValidatorsApy.ts index 42b9cc0f2b7..e8190b06d46 100644 --- a/apps/core/src/hooks/useGetValidatorsApy.ts +++ b/apps/core/src/hooks/useGetValidatorsApy.ts @@ -13,11 +13,13 @@ import { roundFloat } from '../utils/roundFloat'; const DEFAULT_APY_DECIMALS = 2; +export interface ValidatorApyData { + apy: number; + isApyApproxZero: boolean; +} + export interface ApyByValidator { - [validatorAddress: string]: { - apy: number; - isApyApproxZero: boolean; - }; + [validatorAddress: string]: ValidatorApyData; } // For small APY, show ~0% instead of 0% // If APY falls below 0.001, show ~0% instead of 0% since we round to 2 decimal places diff --git a/apps/core/src/utils/formatApy.ts b/apps/core/src/utils/formatApy.ts new file mode 100644 index 00000000000..68872142c3a --- /dev/null +++ b/apps/core/src/utils/formatApy.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +export function formatApy(apy: number, isApyApproxZero: boolean = false): string { + return isApyApproxZero ? '~ 0%' : `${apy}%`; +} diff --git a/apps/core/src/utils/index.ts b/apps/core/src/utils/index.ts index 8cbb31afa73..dc345cc7990 100644 --- a/apps/core/src/utils/index.ts +++ b/apps/core/src/utils/index.ts @@ -21,6 +21,7 @@ export * from './getDelegationDataByStakeId'; export * from './api-env'; export * from './getExplorerPaths'; export * from './getExplorerLink'; +export * from './formatApy'; export * from './stake'; export * from './transaction'; diff --git a/apps/core/src/utils/stake/getTotalValidatorStake.ts b/apps/core/src/utils/stake/getTotalValidatorStake.ts new file mode 100644 index 00000000000..99f482d1253 --- /dev/null +++ b/apps/core/src/utils/stake/getTotalValidatorStake.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { IotaValidatorSummary } from '@iota/iota-sdk/client'; + +export function getTotalValidatorStake(validatorSummary: IotaValidatorSummary | null) { + return validatorSummary?.stakingPoolIotaBalance || 0; +} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx index 6007798119b..2b4f001f844 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx +++ b/apps/wallet-dashboard/components/Dialogs/Staking/StakeDialog.tsx @@ -28,6 +28,7 @@ import { prepareObjectsForTimelockedStakingTransaction } from '@/lib/utils'; import { Dialog } from '@iota/apps-ui-kit'; import { DetailsView, UnstakeView } from './views'; import { FormValues } from './views/EnterAmountView'; +import { FinishStakingView } from './views/FinishStaking'; export const MIN_NUMBER_IOTA_TO_STAKE = 1; @@ -36,6 +37,7 @@ export enum StakeDialogView { SelectValidator = 'SelectValidator', EnterAmount = 'EnterAmount', Unstake = 'Unstake', + TransactionDetails = 'TransactionDetails', } const INITIAL_VALUES = { @@ -44,19 +46,16 @@ const INITIAL_VALUES = { interface StakeDialogProps { isTimelockedStaking?: boolean; - onSuccess?: (digest: string) => void; isOpen: boolean; handleClose: () => void; view: StakeDialogView; - setView?: (view: StakeDialogView) => void; + setView: (view: StakeDialogView) => void; stakedDetails?: ExtendedDelegatedStake | null; - selectedValidator?: string; setSelectedValidator?: (validator: string) => void; } export function StakeDialog({ - onSuccess, isTimelockedStaking, isOpen, handleClose, @@ -125,8 +124,10 @@ export function StakeDialog({ const validators = Object.keys(rollingAverageApys ?? {}) ?? []; + const validatorApy = rollingAverageApys?.[selectedValidator] ?? null; + function handleBack(): void { - setView?.(StakeDialogView.SelectValidator); + setView(StakeDialogView.SelectValidator); } function handleValidatorSelect(validator: string): void { @@ -135,16 +136,16 @@ export function StakeDialog({ function selectValidatorHandleNext(): void { if (selectedValidator) { - setView?.(StakeDialogView.EnterAmount); + setView(StakeDialogView.EnterAmount); } } function detailsHandleUnstake() { - setView?.(StakeDialogView.Unstake); + setView(StakeDialogView.Unstake); } function detailsHandleStake() { - setView?.(StakeDialogView.SelectValidator); + setView(StakeDialogView.SelectValidator); } function handleStake(): void { @@ -156,25 +157,21 @@ export function StakeDialog({ addNotification('Stake transaction was not created', NotificationType.Error); return; } + signAndExecuteTransaction( { transaction: newStakeData?.transaction, }, { - onSuccess: (tx) => { - if (onSuccess) { - onSuccess(tx.digest); - } + onSuccess: () => { + setView(StakeDialogView.TransactionDetails); + addNotification('Stake transaction has been sent'); + }, + onError: () => { + addNotification('Stake transaction was not sent', NotificationType.Error); }, }, - ) - .then(() => { - handleClose(); - addNotification('Stake transaction has been sent'); - }) - .catch(() => { - addNotification('Stake transaction was not sent', NotificationType.Error); - }); + ); } function onSubmit(_: FormValues, { resetForm }: FormikHelpers) { @@ -220,6 +217,18 @@ export function StakeDialog({ showActiveStatus /> )} + {view === StakeDialogView.TransactionDetails && ( + + )} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx index 5016a5e97ca..870c5d72ab1 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/EnterAmountView.tsx @@ -2,21 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { - useFormatCoin, - useBalance, - CoinFormat, - parseAmount, - useCoinMetadata, - useStakeTxnInfo, -} from '@iota/core'; +import { useFormatCoin, useBalance, CoinFormat, parseAmount, useCoinMetadata } from '@iota/core'; import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; import { Button, ButtonType, - KeyValueInfo, - Panel, - Divider, Input, InputType, Header, @@ -26,11 +16,8 @@ import { } 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'; - -import { Validator } from './Validator'; -import { StakedInfo } from './StakedInfo'; -import { Layout, LayoutBody, LayoutFooter } from './Layout'; +import { useCurrentAccount } from '@iota/dapp-kit'; +import { StakingRewardDetails, Validator, StakedInfo, Layout, LayoutBody, LayoutFooter } from './'; export interface FormValues { amount: string; @@ -46,7 +33,7 @@ interface EnterAmountViewProps { isTransactionLoading?: boolean; } -function EnterAmountView({ +export function EnterAmountView({ selectedValidator: selectedValidatorAddress, onBack, onStake, @@ -64,12 +51,10 @@ function EnterAmountView({ 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( @@ -82,10 +67,6 @@ function EnterAmountView({ ? '--' : `${maxTokenFormatted} ${maxTokenFormattedSymbol} Available`; - const { stakedRewardsStartEpoch, timeBeforeStakeRewardsRedeemableAgoDisplay } = useStakeTxnInfo( - system?.epoch, - ); - const hasEnoughRemaingBalance = maxTokenBalance > parseAmount(values.amount, decimals) + BigInt(2) * gasBudgetBigInt; const shouldShowInsufficientRemainingFundsWarning = @@ -124,7 +105,7 @@ function EnterAmountView({ type={InputType.NumericFormat} label="Amount" value={amount} - suffix={` ${symbol}`} + suffix={` ${metadata?.symbol}`} placeholder="Enter amount to stake" errorMessage={ values.amount && meta.error ? meta.error : undefined @@ -145,28 +126,7 @@ function EnterAmountView({ ) : null} - - -
- - - - -
-
+ @@ -185,5 +145,3 @@ function EnterAmountView({ ); } - -export default EnterAmountView; diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/FinishStaking.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/FinishStaking.tsx new file mode 100644 index 00000000000..d2126607329 --- /dev/null +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/FinishStaking.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { + Button, + ButtonType, + Card, + CardBody, + CardImage, + CardType, + Header, + ImageType, +} from '@iota/apps-ui-kit'; +import { CoinIcon, ImageIconSize, ValidatorApyData } from '@iota/core'; +import { Validator } from './Validator'; +import { StakingRewardDetails } from './StakingRewardDetails'; +import { Layout, LayoutBody, LayoutFooter } from './Layout'; +import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; + +interface FinishStakingViewProps { + validatorAddress: string; + gasBudget: string | number | null | undefined; + onConfirm: () => void; + amount: string; + symbol: string | undefined; + validatorApy?: ValidatorApyData | null; + onClose: () => void; + showActiveStatus?: boolean; +} + +export function FinishStakingView({ + validatorAddress, + onConfirm, + amount, + symbol, + onClose, + validatorApy, + gasBudget, + showActiveStatus, +}: FinishStakingViewProps): React.JSX.Element { + return ( + +
+ +
+ + + + + + + + + + +
+
+ + +
+
+
+ + ); +} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/StakingRewardDetails.tsx b/apps/wallet-dashboard/components/Dialogs/Staking/views/StakingRewardDetails.tsx new file mode 100644 index 00000000000..c6677dcf92e --- /dev/null +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/StakingRewardDetails.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +import { Divider, KeyValueInfo, Panel } from '@iota/apps-ui-kit'; +import { formatApy, useFormatCoin, useStakeTxnInfo, ValidatorApyData } from '@iota/core'; +import { useIotaClientQuery } from '@iota/dapp-kit'; +import { IOTA_TYPE_ARG } from '@iota/iota-sdk/utils'; + +interface StakingRewardDetailsProps { + gasBudget: string | number | null | undefined; + validatorApy?: ValidatorApyData | null; +} + +export function StakingRewardDetails({ + gasBudget, + validatorApy, +}: StakingRewardDetailsProps): React.JSX.Element { + const { apy, isApyApproxZero } = validatorApy || {}; + const [gas, gasSymbol] = useFormatCoin(gasBudget, IOTA_TYPE_ARG); + const { data: system } = useIotaClientQuery('getLatestIotaSystemState'); + + const { stakedRewardsStartEpoch, timeBeforeStakeRewardsRedeemableAgoDisplay } = useStakeTxnInfo( + system?.epoch, + ); + + return ( + +
+ {apy !== null && apy !== undefined ? ( + + ) : null} + + + + + +
+
+ ); +} diff --git a/apps/wallet-dashboard/components/Dialogs/Staking/views/index.ts b/apps/wallet-dashboard/components/Dialogs/Staking/views/index.ts index 69e70ed7315..0bb0c72c335 100644 --- a/apps/wallet-dashboard/components/Dialogs/Staking/views/index.ts +++ b/apps/wallet-dashboard/components/Dialogs/Staking/views/index.ts @@ -1,7 +1,12 @@ // Copyright (c) 2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -export { default as EnterAmountView } from './EnterAmountView'; export { default as SelectValidatorView } from './SelectValidatorView'; + +export * from './EnterAmountView'; export * from './DetailsView'; export * from './UnstakeView'; +export * from './StakingRewardDetails'; +export * from './Validator'; +export * from './StakedInfo'; +export * from './Layout';