From 3f7904b3ddd0087f5e5b34188f9d2f361ee57592 Mon Sep 17 00:00:00 2001 From: Yurixander <101931215+yurixander@users.noreply.github.com> Date: Mon, 22 Jul 2024 04:19:10 -0700 Subject: [PATCH] feat(tangle-dapp): Add liquid staking unstaking card (#2415) Co-authored-by: vutuanlinh2k2 --- .yarnrc | 5 - .../hooks/useFormattedAmountForSygmaTx.ts | 2 +- .../app/liquid-staking/[tokenSymbol]/page.tsx | 36 +-- apps/tangle-dapp/app/liquid-staking/page.tsx | 59 ++--- .../components/LiquidStaking/AddressLink.tsx | 37 +++ .../LiquidStaking/AvailableWithdrawCard.tsx | 84 +++++++ .../LiquidStaking/CancelUnstakeModal.tsx | 74 ++++++ .../components/LiquidStaking/ChainLogo.tsx | 61 +++-- .../components/LiquidStaking/DetailItem.tsx | 43 ++++ .../LiquidStaking/DropdownChevronIcon.tsx | 21 ++ .../LiquidStaking/ExchangeRateDetailItem.tsx | 47 ++++ .../components/LiquidStaking/ExternalLink.tsx | 32 +++ .../LiquidStaking/HoverButtonStyle.tsx | 11 - .../LiquidStakeAndUnstakeCards.tsx | 48 ++++ ...uidStakingCard.tsx => LiquidStakeCard.tsx} | 185 ++++++-------- .../LiquidStaking/LiquidStakingInput.tsx | 149 ++++++----- .../LiquidStaking/LiquidStakingTokenItem.tsx | 27 +- .../LiquidStaking/LiquidUnstakeCard.tsx | 236 ++++++++++++++++++ .../MintAndRedeemFeeDetailItem.tsx | 57 +++++ .../components/LiquidStaking/ModalIcon.tsx | 42 ++++ .../LiquidStaking/ParachainWalletBalance.tsx | 130 ++++++++++ .../LiquidStaking/SelectTokenModal.tsx | 134 ++++++++++ .../LiquidStaking/SelectValidators.tsx | 25 -- .../LiquidStaking/SelectValidatorsButton.tsx | 24 ++ .../LiquidStaking/StakedAssetsTable.tsx | 136 ++++++++++ .../components/LiquidStaking/TokenChip.tsx | 61 +++++ .../LiquidStaking/TokenInfoCard.tsx | 182 -------------- .../LiquidStaking/UnstakePeriodDetailItem.tsx | 20 ++ .../UnstakeRequestSubmittedModal.tsx | 77 ++++++ .../LiquidStaking/UnstakeRequestsTable.tsx | 230 +++++++++++++++++ .../LiquidStaking/WalletBalance.tsx | 17 -- .../NetworkSelectionButton.tsx | 2 +- .../components/RestakeDetailCard/utils.ts | 4 +- .../components/Sidebar/sidebarProps.ts | 2 +- .../UnbondingStatsItem/UnbondingStatsItem.tsx | 4 +- .../account/WithdrawEvmBalanceAction.tsx | 4 + .../components/tableCells/TokenAmountCell.tsx | 25 +- apps/tangle-dapp/constants/liquidStaking.ts | 165 ++++++++---- .../liquidStaking/getValueOfTangleCurrency.ts | 53 ++++ .../useDelegationsOccupiedStatus.ts | 13 + .../data/liquidStaking/useExchangeRate.ts | 58 +++++ .../liquidStaking/useMintAndRedeemFees.ts | 27 ++ .../data/liquidStaking/useMintTx.ts | 19 +- .../data/liquidStaking/useOngoingTimeUnits.ts | 32 +++ .../liquidStaking/useParachainBalances.ts | 13 +- .../data/liquidStaking/useRedeemTx.ts | 15 +- .../liquidStaking/useTokenUnlockDurations.ts | 29 +++ apps/tangle-dapp/hooks/useInputAmount.ts | 40 ++- apps/tangle-dapp/hooks/useLSTokenSVGs.ts | 3 +- apps/tangle-dapp/hooks/useSubstrateTx.ts | 11 +- apps/tangle-dapp/hooks/useTxNotification.tsx | 2 +- apps/tangle-dapp/tailwind.config.js | 3 + apps/tangle-dapp/types/utils.ts | 11 + apps/tangle-dapp/utils/addCommasToNumber.ts | 42 ++++ .../utils/assertAnySubstrateAddress.ts | 12 + .../utils/calculateBnPercentage.ts | 39 --- apps/tangle-dapp/utils/calculateBnRatio.ts | 34 +++ .../utils/calculateTimeRemaining.ts | 5 +- apps/tangle-dapp/utils/formatBn.ts | 85 ++++--- apps/tangle-dapp/utils/formatBnWithCommas.ts | 30 --- apps/tangle-dapp/utils/formatTangleBalance.ts | 1 + .../utils/isAnySubstrateAddress.ts | 11 + .../utils/liquidStaking/addTimeUnits.ts | 47 ++++ apps/tangle-dapp/utils/permillToPercentage.ts | 7 + .../tangle-dapp/utils/scaleAmountByPermill.ts | 14 ++ libs/icons/src/TimeFillIcon.tsx | 11 + libs/icons/src/UndoIcon.tsx | 11 + libs/icons/src/index.ts | 2 + .../src/components/buttons/IconButton.tsx | 16 +- .../src/components/buttons/types.ts | 4 +- 70 files changed, 2472 insertions(+), 726 deletions(-) delete mode 100644 .yarnrc create mode 100644 apps/tangle-dapp/components/LiquidStaking/AddressLink.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/AvailableWithdrawCard.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/CancelUnstakeModal.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/DetailItem.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/DropdownChevronIcon.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/ExchangeRateDetailItem.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/ExternalLink.tsx delete mode 100644 apps/tangle-dapp/components/LiquidStaking/HoverButtonStyle.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/LiquidStakeAndUnstakeCards.tsx rename apps/tangle-dapp/components/LiquidStaking/{LiquidStakingCard.tsx => LiquidStakeCard.tsx} (51%) create mode 100644 apps/tangle-dapp/components/LiquidStaking/LiquidUnstakeCard.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/MintAndRedeemFeeDetailItem.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/ModalIcon.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/ParachainWalletBalance.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/SelectTokenModal.tsx delete mode 100644 apps/tangle-dapp/components/LiquidStaking/SelectValidators.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/SelectValidatorsButton.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/StakedAssetsTable.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/TokenChip.tsx delete mode 100644 apps/tangle-dapp/components/LiquidStaking/TokenInfoCard.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/UnstakePeriodDetailItem.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/UnstakeRequestSubmittedModal.tsx create mode 100644 apps/tangle-dapp/components/LiquidStaking/UnstakeRequestsTable.tsx delete mode 100644 apps/tangle-dapp/components/LiquidStaking/WalletBalance.tsx create mode 100644 apps/tangle-dapp/data/liquidStaking/getValueOfTangleCurrency.ts create mode 100644 apps/tangle-dapp/data/liquidStaking/useDelegationsOccupiedStatus.ts create mode 100644 apps/tangle-dapp/data/liquidStaking/useExchangeRate.ts create mode 100644 apps/tangle-dapp/data/liquidStaking/useMintAndRedeemFees.ts create mode 100644 apps/tangle-dapp/data/liquidStaking/useOngoingTimeUnits.ts create mode 100644 apps/tangle-dapp/data/liquidStaking/useTokenUnlockDurations.ts create mode 100644 apps/tangle-dapp/utils/addCommasToNumber.ts create mode 100644 apps/tangle-dapp/utils/assertAnySubstrateAddress.ts delete mode 100644 apps/tangle-dapp/utils/calculateBnPercentage.ts create mode 100644 apps/tangle-dapp/utils/calculateBnRatio.ts delete mode 100644 apps/tangle-dapp/utils/formatBnWithCommas.ts create mode 100644 apps/tangle-dapp/utils/isAnySubstrateAddress.ts create mode 100644 apps/tangle-dapp/utils/liquidStaking/addTimeUnits.ts create mode 100644 apps/tangle-dapp/utils/permillToPercentage.ts create mode 100644 apps/tangle-dapp/utils/scaleAmountByPermill.ts create mode 100644 libs/icons/src/TimeFillIcon.tsx create mode 100644 libs/icons/src/UndoIcon.tsx diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index 85b738b8d..000000000 --- a/.yarnrc +++ /dev/null @@ -1,5 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -yarn-path ".yarn/releases/yarn-1.22.22.cjs" diff --git a/apps/tangle-dapp/app/bridge/hooks/useFormattedAmountForSygmaTx.ts b/apps/tangle-dapp/app/bridge/hooks/useFormattedAmountForSygmaTx.ts index 6db960e72..ebce81f8b 100644 --- a/apps/tangle-dapp/app/bridge/hooks/useFormattedAmountForSygmaTx.ts +++ b/apps/tangle-dapp/app/bridge/hooks/useFormattedAmountForSygmaTx.ts @@ -17,7 +17,7 @@ export default function useAmountToTransfer() { ? parseUnits( formatBn(amount, decimals, { includeCommas: false, - fractionLength: undefined, + fractionMaxLength: undefined, }), decimals, ).toString() diff --git a/apps/tangle-dapp/app/liquid-staking/[tokenSymbol]/page.tsx b/apps/tangle-dapp/app/liquid-staking/[tokenSymbol]/page.tsx index 04b954e94..50c18ca64 100644 --- a/apps/tangle-dapp/app/liquid-staking/[tokenSymbol]/page.tsx +++ b/apps/tangle-dapp/app/liquid-staking/[tokenSymbol]/page.tsx @@ -1,9 +1,10 @@ +'use client'; + import { notFound } from 'next/navigation'; import { FC } from 'react'; -import LiquidStakingCard from '../../../components/LiquidStaking/LiquidStakingCard'; -import TokenInfoCard from '../../../components/LiquidStaking/TokenInfoCard'; -import { LIQUID_STAKING_TOKEN_PREFIX } from '../../../constants/liquidStaking'; +import LiquidStakeAndUnstakeCards from '../../../components/LiquidStaking/LiquidStakeAndUnstakeCards'; +import UnstakeRequestsTable from '../../../components/LiquidStaking/UnstakeRequestsTable'; import isLiquidStakingToken from '../../../utils/liquidStaking/isLiquidStakingToken'; type Props = { @@ -17,33 +18,10 @@ const LiquidStakingTokenPage: FC = ({ params: { tokenSymbol } }) => { } return ( -
- +
+ - +
); }; diff --git a/apps/tangle-dapp/app/liquid-staking/page.tsx b/apps/tangle-dapp/app/liquid-staking/page.tsx index 9169bec62..826bdffe5 100644 --- a/apps/tangle-dapp/app/liquid-staking/page.tsx +++ b/apps/tangle-dapp/app/liquid-staking/page.tsx @@ -4,16 +4,11 @@ import { FC } from 'react'; import { GlassCard } from '../../components'; import LiquidStakingTokenItem from '../../components/LiquidStaking/LiquidStakingTokenItem'; import StatItem from '../../components/LiquidStaking/StatItem'; -import { LS_CHAIN_TO_TOKEN, TVS_TOOLTIP } from '../../constants/liquidStaking'; -import entriesOf from '../../utils/entriesOf'; +import { LIQUID_STAKING_CHAINS } from '../../constants/liquidStaking'; const LiquidStakingPage: FC = () => { return ( -
- - Overview - - +
@@ -22,44 +17,40 @@ const LiquidStakingPage: FC = () => { Get Liquid Staking Tokens (LSTs) to earn & unleash restaking on - Tangle via delegation. + Tangle Mainnet via delegation.
-
+
- - - -
- - +
+ Liquid Staking Tokens -
-
- {entriesOf(LS_CHAIN_TO_TOKEN).map(([chain, token]) => { - return ( - - ); - })} + +
+
+ {LIQUID_STAKING_CHAINS.map((chain) => { + return ( + + ); + })} +
-
- + +
); }; diff --git a/apps/tangle-dapp/components/LiquidStaking/AddressLink.tsx b/apps/tangle-dapp/components/LiquidStaking/AddressLink.tsx new file mode 100644 index 000000000..ed2761893 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/AddressLink.tsx @@ -0,0 +1,37 @@ +import { HexString } from '@polkadot/util/types'; +import { ExternalLinkLine } from '@webb-tools/icons'; +import { shortenString, Typography } from '@webb-tools/webb-ui-components'; +import { FC, useCallback } from 'react'; + +import { AnySubstrateAddress } from '../../types/utils'; + +export type AddressLinkProps = { + address: AnySubstrateAddress | HexString; +}; + +const AddressLink: FC = ({ address }) => { + // TODO: Determine href. + const href = '#'; + + // Stop propagation to prevent a parent modal (if any) from closing. + const handleClick = useCallback((event: any) => { + event.stopPropagation(); + }, []); + + return ( + + + {shortenString(address, 6)} + + + + + ); +}; + +export default AddressLink; diff --git a/apps/tangle-dapp/components/LiquidStaking/AvailableWithdrawCard.tsx b/apps/tangle-dapp/components/LiquidStaking/AvailableWithdrawCard.tsx new file mode 100644 index 000000000..f99fe93ba --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/AvailableWithdrawCard.tsx @@ -0,0 +1,84 @@ +import { CheckboxCircleFill, TimeFillIcon, UndoIcon } from '@webb-tools/icons'; +import { Button, Typography } from '@webb-tools/webb-ui-components'; +import { FC, ReactElement } from 'react'; + +import GlassCard from '../GlassCard'; + +const AvailableWithdrawCard: FC = () => { + return ( + +
+
+ + Available + + + + 0.0 DOT + +
+ +
+ + + {/* TODO: Need a tooltip for this, since it's only an icon. */} + +
+
+ +
+ +
+
+ + My requests + + +
+ } + text="0" + /> + + } + text="1" + /> +
+
+ +
+ + + + 1.00 tgDOT + +
+
+
+ ); +}; + +type RequestItemProps = { + icon: ReactElement; + text: string; +}; + +/** @internal */ +const RequestItem: FC = ({ icon, text }) => { + return ( +
+ {icon} + + + {text} + +
+ ); +}; + +export default AvailableWithdrawCard; diff --git a/apps/tangle-dapp/components/LiquidStaking/CancelUnstakeModal.tsx b/apps/tangle-dapp/components/LiquidStaking/CancelUnstakeModal.tsx new file mode 100644 index 000000000..db67c499e --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/CancelUnstakeModal.tsx @@ -0,0 +1,74 @@ +import { CloseCircleLineIcon } from '@webb-tools/icons'; +import { + Button, + Modal, + ModalContent, + ModalFooter, + ModalHeader, + Typography, +} from '@webb-tools/webb-ui-components'; +import { FC, useCallback } from 'react'; + +import ExternalLink from './ExternalLink'; +import ModalIcon from './ModalIcon'; +import { UnstakeRequestItem } from './UnstakeRequestsTable'; + +export type CancelUnstakeModalProps = { + isOpen: boolean; + unstakeRequest: UnstakeRequestItem; + onClose: () => void; +}; + +const CancelUnstakeModal: FC = ({ + isOpen, + // TODO: Make use of the unstake request data, which is relevant for the link's href. + unstakeRequest: _unstakeRequest, + onClose, +}) => { + const handleConfirm = useCallback(() => { + // TODO: Set button as loading, disable ability to close modal, and proceed to execute the unstake cancellation via its corresponding extrinsic call. + }, []); + + return ( + + + + Cancel Unstake + + +
+ + + + Are you sure you want to cancel your unstaking request? By + cancelling, your tokens will remain staked and continue earning + rewards. + + + {/* TODO: External link's href. */} + Learn More +
+ + + + + + +
+
+ ); +}; + +export default CancelUnstakeModal; diff --git a/apps/tangle-dapp/components/LiquidStaking/ChainLogo.tsx b/apps/tangle-dapp/components/LiquidStaking/ChainLogo.tsx index 13b75b636..280d215f3 100644 --- a/apps/tangle-dapp/components/LiquidStaking/ChainLogo.tsx +++ b/apps/tangle-dapp/components/LiquidStaking/ChainLogo.tsx @@ -3,16 +3,17 @@ import { FC } from 'react'; import { twMerge } from 'tailwind-merge'; import { - LiquidStakingChain, - LS_CHAIN_TO_LOGO, + LIQUID_STAKING_CHAIN_MAP, + LiquidStakingChainId, } from '../../constants/liquidStaking'; export type ChainLogoSize = 'sm' | 'md'; export type ChainLogoProps = { - chain: LiquidStakingChain; + chainId?: LiquidStakingChainId; size: ChainLogoSize; isRounded?: boolean; + isLiquidVariant?: boolean; }; const getSizeNumber = (size: ChainLogoSize) => { @@ -33,17 +34,21 @@ const getSizeClass = (size: ChainLogoSize) => { } }; -const getBackgroundColor = (chain: LiquidStakingChain) => { +const getBackgroundColor = (chain: LiquidStakingChainId) => { switch (chain) { - case LiquidStakingChain.MANTA: + case LiquidStakingChainId.MANTA: return 'bg-[#13101D] dark:bg-[#13101D]'; - case LiquidStakingChain.MOONBEAM: + case LiquidStakingChainId.MOONBEAM: return 'bg-[#1d1336] dark:bg-[#1d1336]'; - case LiquidStakingChain.PHALA: + case LiquidStakingChainId.PHALA: return 'bg-black dark:bg-black'; - case LiquidStakingChain.POLKADOT: + case LiquidStakingChainId.POLKADOT: return 'bg-mono-0 dark:bg-mono-0'; - case LiquidStakingChain.ASTAR: + case LiquidStakingChainId.TANGLE_RESTAKING_PARACHAIN: + // Fix the icon SVG getting cut off on the sides by adding + // a matching background. + return 'bg-[#f6f4ff]'; + case LiquidStakingChainId.ASTAR: // No background for Astar, since it looks better without // a background. return ''; @@ -51,24 +56,44 @@ const getBackgroundColor = (chain: LiquidStakingChain) => { }; const ChainLogo: FC = ({ - chain, + chainId, size = 'md', isRounded = false, + isLiquidVariant = false, }) => { const sizeNumber = getSizeNumber(size); + // In case the chain id is not provided, render a placeholder. + if (chainId === undefined) { + return ( +
+ ); + } + return ( - {`Logo + > + {`Logo +
); }; diff --git a/apps/tangle-dapp/components/LiquidStaking/DetailItem.tsx b/apps/tangle-dapp/components/LiquidStaking/DetailItem.tsx new file mode 100644 index 000000000..7ced70e2a --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/DetailItem.tsx @@ -0,0 +1,43 @@ +import { InformationLine } from '@webb-tools/icons'; +import { IconWithTooltip, Typography } from '@webb-tools/webb-ui-components'; +import { FC, ReactNode } from 'react'; + +type DetailItemProps = { + title: string; + tooltip?: string; + value: ReactNode | string; +}; + +const DetailItem: FC = ({ title, tooltip, value }) => { + return ( +
+
+ + {title} + + + {tooltip !== undefined && ( + + } + content={tooltip} + overrideTooltipBodyProps={{ + className: 'max-w-[350px]', + }} + /> + )} +
+ + {typeof value === 'string' ? ( + + {value} + + ) : ( + value + )} +
+ ); +}; + +export default DetailItem; diff --git a/apps/tangle-dapp/components/LiquidStaking/DropdownChevronIcon.tsx b/apps/tangle-dapp/components/LiquidStaking/DropdownChevronIcon.tsx new file mode 100644 index 000000000..c2978c4c9 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/DropdownChevronIcon.tsx @@ -0,0 +1,21 @@ +import { ChevronDown } from '@webb-tools/icons'; +import { FC } from 'react'; + +export type DropdownChevronIconProps = { + isLarge?: boolean; +}; + +const DropdownChevronIcon: FC = ({ + isLarge = false, +}) => { + return ( +
+ +
+ ); +}; + +export default DropdownChevronIcon; diff --git a/apps/tangle-dapp/components/LiquidStaking/ExchangeRateDetailItem.tsx b/apps/tangle-dapp/components/LiquidStaking/ExchangeRateDetailItem.tsx new file mode 100644 index 000000000..4bad75ff8 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/ExchangeRateDetailItem.tsx @@ -0,0 +1,47 @@ +import { SkeletonLoader } from '@webb-tools/webb-ui-components'; +import { FC } from 'react'; + +import { + LIQUID_STAKING_TOKEN_PREFIX, + LiquidStakingCurrency, + LiquidStakingToken, +} from '../../constants/liquidStaking'; +import useExchangeRate, { + ExchangeRateType, +} from '../../data/liquidStaking/useExchangeRate'; +import DetailItem from './DetailItem'; + +export type ExchangeRateDetailItemProps = { + type: ExchangeRateType; + token: LiquidStakingToken; + currency: LiquidStakingCurrency; +}; + +const ExchangeRateDetailItem: FC = ({ + type, + token, + currency, +}) => { + const exchangeRate = useExchangeRate(type, currency); + + const exchangeRateElement = + exchangeRate === null ? ( + + ) : ( + exchangeRate + ); + + return ( + + 1 {token} = {exchangeRateElement} {LIQUID_STAKING_TOKEN_PREFIX} + {token} +
+ } + /> + ); +}; + +export default ExchangeRateDetailItem; diff --git a/apps/tangle-dapp/components/LiquidStaking/ExternalLink.tsx b/apps/tangle-dapp/components/LiquidStaking/ExternalLink.tsx new file mode 100644 index 000000000..934be15aa --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/ExternalLink.tsx @@ -0,0 +1,32 @@ +import { ExternalLinkLine } from '@webb-tools/icons'; +import { IconBase } from '@webb-tools/icons/types'; +import { Button } from '@webb-tools/webb-ui-components'; +import { FC, ReactNode } from 'react'; + +export type ExternalLinkProps = { + href: string; + children: ReactNode | string; + Icon?: (props: IconBase) => ReactNode; +}; + +const ExternalLink: FC = ({ + href, + children, + Icon = ExternalLinkLine, +}) => { + return ( + + ); +}; + +export default ExternalLink; diff --git a/apps/tangle-dapp/components/LiquidStaking/HoverButtonStyle.tsx b/apps/tangle-dapp/components/LiquidStaking/HoverButtonStyle.tsx deleted file mode 100644 index ca41fd8ef..000000000 --- a/apps/tangle-dapp/components/LiquidStaking/HoverButtonStyle.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { FC, ReactNode } from 'react'; - -const HoverButtonStyle: FC<{ children: ReactNode }> = ({ children }) => { - return ( -
- {children} -
- ); -}; - -export default HoverButtonStyle; diff --git a/apps/tangle-dapp/components/LiquidStaking/LiquidStakeAndUnstakeCards.tsx b/apps/tangle-dapp/components/LiquidStaking/LiquidStakeAndUnstakeCards.tsx new file mode 100644 index 000000000..a6d4e7235 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/LiquidStakeAndUnstakeCards.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { Typography } from '@webb-tools/webb-ui-components'; +import { FC, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import LiquidStakeCard from './LiquidStakeCard'; +import LiquidUnstakeCard from './LiquidUnstakeCard'; + +const LiquidStakeAndUnstakeCards: FC = () => { + const [isStaking, setIsStaking] = useState(true); + const selectedClass = 'dark:text-mono-0'; + const unselectedClass = 'text-mono-100 dark:text-mono-100'; + + return ( +
+
+ setIsStaking(true)} + className={twMerge( + isStaking ? selectedClass : unselectedClass, + !isStaking && 'cursor-pointer', + )} + variant="h4" + fw="bold" + > + Stake + + + setIsStaking(false)} + className={twMerge( + !isStaking ? selectedClass : unselectedClass, + isStaking && 'cursor-pointer', + )} + variant="h4" + fw="bold" + > + Unstake + +
+ + {isStaking ? : } +
+ ); +}; + +export default LiquidStakeAndUnstakeCards; diff --git a/apps/tangle-dapp/components/LiquidStaking/LiquidStakingCard.tsx b/apps/tangle-dapp/components/LiquidStaking/LiquidStakeCard.tsx similarity index 51% rename from apps/tangle-dapp/components/LiquidStaking/LiquidStakingCard.tsx rename to apps/tangle-dapp/components/LiquidStaking/LiquidStakeCard.tsx index 7eb6ddaaa..d6955e772 100644 --- a/apps/tangle-dapp/components/LiquidStaking/LiquidStakingCard.tsx +++ b/apps/tangle-dapp/components/LiquidStaking/LiquidStakeCard.tsx @@ -4,13 +4,12 @@ // the `lstMinting` pallet for this file only. import '@webb-tools/tangle-restaking-types'; -import { BN } from '@polkadot/util'; +import { BN, BN_ZERO } from '@polkadot/util'; import { ArrowDownIcon } from '@radix-ui/react-icons'; -import { InformationLine, Search } from '@webb-tools/icons'; +import { Search } from '@webb-tools/icons'; import { Button, Chip, - IconWithTooltip, Input, Typography, } from '@webb-tools/webb-ui-components'; @@ -18,40 +17,50 @@ import { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK } from '@webb-tools/webb-u import { FC, useCallback, useMemo, useState } from 'react'; import { + LIQUID_STAKING_CHAIN_MAP, LIQUID_STAKING_TOKEN_PREFIX, - LiquidStakingChain, - LS_CHAIN_TO_TOKEN, - LS_TOKEN_TO_CURRENCY, + LiquidStakingChainId, } from '../../constants/liquidStaking'; +import useExchangeRate, { + ExchangeRateType, +} from '../../data/liquidStaking/useExchangeRate'; import useMintTx from '../../data/liquidStaking/useMintTx'; +import useParachainBalances from '../../data/liquidStaking/useParachainBalances'; import useApi from '../../hooks/useApi'; import useApiRx from '../../hooks/useApiRx'; import { TxStatus } from '../../hooks/useSubstrateTx'; +import DetailItem from './DetailItem'; +import ExchangeRateDetailItem from './ExchangeRateDetailItem'; import LiquidStakingInput from './LiquidStakingInput'; -import SelectValidators from './SelectValidators'; -import WalletBalance from './WalletBalance'; +import MintAndRedeemFeeDetailItem from './MintAndRedeemFeeDetailItem'; +import ParachainWalletBalance from './ParachainWalletBalance'; +import SelectValidatorsButton from './SelectValidatorsButton'; +import UnstakePeriodDetailItem from './UnstakePeriodDetailItem'; -const LiquidStakingCard: FC = () => { +const LiquidStakeCard: FC = () => { const [fromAmount, setFromAmount] = useState(null); - // TODO: The rate will likely be a hook on its own, likely needs to be extracted from the Tangle Restaking Parachain via a query/subscription. - const [rate] = useState(1.0); - - const [selectedChain, setSelectedChain] = useState( - LiquidStakingChain.TANGLE_RESTAKING_PARACHAIN, + const [selectedChainId, setSelectedChainId] = useState( + LiquidStakingChainId.TANGLE_RESTAKING_PARACHAIN, ); const { execute: executeMintTx, status: mintTxStatus } = useMintTx(); + const { nativeBalances } = useParachainBalances(); + + const selectedChain = LIQUID_STAKING_CHAIN_MAP[selectedChainId]; - const selectedChainToken = LS_CHAIN_TO_TOKEN[selectedChain]; + const exchangeRate = useExchangeRate( + ExchangeRateType.NativeToLiquid, + selectedChain.currency, + ); const { result: minimumMintingAmount } = useApiRx( useCallback( (api) => api.query.lstMinting.minimumMint({ - Native: LS_TOKEN_TO_CURRENCY[selectedChainToken], + Native: selectedChain.currency, }), - [selectedChainToken], + [selectedChain.currency], ), TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint, ); @@ -69,6 +78,14 @@ const LiquidStakingCard: FC = () => { return BN.max(minimumMintingAmount, existentialDepositAmount); }, [existentialDepositAmount, minimumMintingAmount]); + const maximumInputAmount = useMemo(() => { + if (nativeBalances === null) { + return null; + } + + return nativeBalances.get(selectedChain.token) ?? BN_ZERO; + }, [nativeBalances, selectedChain.token]); + const handleStakeClick = useCallback(() => { if (executeMintTx === null || fromAmount === null) { return; @@ -76,85 +93,84 @@ const LiquidStakingCard: FC = () => { executeMintTx({ amount: fromAmount, - currency: LS_TOKEN_TO_CURRENCY[selectedChainToken], + currency: selectedChain.currency, }); - }, [executeMintTx, fromAmount, selectedChainToken]); + }, [executeMintTx, fromAmount, selectedChain.currency]); const toAmount = useMemo(() => { - if (fromAmount === null || rate === null) { + if (fromAmount === null || exchangeRate === null) { return null; } - return fromAmount.muln(rate); - }, [fromAmount, rate]); + return fromAmount.muln(exchangeRate); + }, [fromAmount, exchangeRate]); - return ( -
-
- - Stake - - - - Unstake - -
+ const walletBalance = ( + setFromAmount(maximumInputAmount)} + /> + ); + return ( + <> } - setChain={setSelectedChain} + decimals={selectedChain.decimals} + onAmountChange={setFromAmount} + placeholder={`0 ${selectedChain.token}`} + rightElement={walletBalance} + setChainId={setSelectedChainId} minAmount={minimumInputAmount ?? undefined} + maxAmount={maximumInputAmount ?? undefined} /> } + token={selectedChain.token} + rightElement={} /> {/* Details */} -
- + - - + - +
-
- ); -}; - -type DetailItemProps = { - title: string; - tooltip?: string; - value: string; -}; - -/** @internal */ -const DetailItem: FC = ({ title, tooltip, value }) => { - return ( -
-
- - {title} - - - {tooltip !== undefined && ( - - } - content={tooltip} - overrideTooltipBodyProps={{ - className: 'max-w-[350px]', - }} - /> - )} -
- - - {value} - -
+ ); }; @@ -250,4 +231,4 @@ export const SelectParachainContent: FC = ({ ); }; -export default LiquidStakingCard; +export default LiquidStakeCard; diff --git a/apps/tangle-dapp/components/LiquidStaking/LiquidStakingInput.tsx b/apps/tangle-dapp/components/LiquidStaking/LiquidStakingInput.tsx index 1ee425b5d..a2c5a857d 100644 --- a/apps/tangle-dapp/components/LiquidStaking/LiquidStakingInput.tsx +++ b/apps/tangle-dapp/components/LiquidStaking/LiquidStakingInput.tsx @@ -2,8 +2,6 @@ import { BN } from '@polkadot/util'; import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; -import { TANGLE_TOKEN_DECIMALS } from '@webb-tools/dapp-config'; -import { ChevronDown } from '@webb-tools/icons'; import { Dropdown, DropdownBody, @@ -11,47 +9,56 @@ import { Typography, } from '@webb-tools/webb-ui-components'; import { ScrollArea } from '@webb-tools/webb-ui-components/components/ScrollArea'; -import { FC, ReactNode, useCallback } from 'react'; +import { FC, ReactNode, useEffect } from 'react'; import { twMerge } from 'tailwind-merge'; import { LIQUID_STAKING_TOKEN_PREFIX, - LiquidStakingChain, + LiquidStakingChainId, LiquidStakingToken, LS_CHAIN_TO_NETWORK_NAME, - LS_TOKEN_TO_CHAIN, } from '../../constants/liquidStaking'; +import { ERROR_NOT_ENOUGH_BALANCE } from '../../containers/ManageProfileModalContainer/Independent/IndependentAllocationInput'; import useInputAmount from '../../hooks/useInputAmount'; import formatBn from '../../utils/formatBn'; import ChainLogo from './ChainLogo'; -import HoverButtonStyle from './HoverButtonStyle'; +import DropdownChevronIcon from './DropdownChevronIcon'; +import TokenChip from './TokenChip'; export type LiquidStakingInputProps = { id: string; - chain: LiquidStakingChain; - setChain?: (newChain: LiquidStakingChain) => void; + chainId: LiquidStakingChainId; + decimals: number; amount: BN | null; - setAmount?: (newAmount: BN | null) => void; isReadOnly?: boolean; placeholder?: string; rightElement?: ReactNode; token: LiquidStakingToken; isTokenLiquidVariant?: boolean; minAmount?: BN; + maxAmount?: BN; + maxErrorMessage?: string; + onAmountChange?: (newAmount: BN | null) => void; + setChainId?: (newChain: LiquidStakingChainId) => void; + onTokenClick?: () => void; }; const LiquidStakingInput: FC = ({ id, amount, - setAmount, + decimals, isReadOnly = false, placeholder = '0', isTokenLiquidVariant = false, rightElement, - chain, - setChain, + chainId, token, minAmount, + maxAmount, + maxErrorMessage = ERROR_NOT_ENOUGH_BALANCE, + onAmountChange, + setChainId, + onTokenClick, }) => { const minErrorMessage = ((): string | undefined => { if (minAmount === undefined) { @@ -60,29 +67,36 @@ const LiquidStakingInput: FC = ({ const unit = `${isTokenLiquidVariant ? LIQUID_STAKING_TOKEN_PREFIX : ''}${token}`; - // TODO: Must consider the chain's token decimals, not always the Tangle token decimals. - const formattedMinAmount = formatBn(minAmount, TANGLE_TOKEN_DECIMALS); + const formattedMinAmount = formatBn(minAmount, decimals, { + fractionMaxLength: undefined, + includeCommas: true, + }); return `Amount must be at least ${formattedMinAmount} ${unit}`; })(); - const { displayAmount, handleChange, errorMessage } = useInputAmount({ + const { + displayAmount, + handleChange, + errorMessage, + updateDisplayAmountManual, + } = useInputAmount({ amount, - setAmount, - // TODO: Decimals must be based on the active token's chain decimals, not always the Tangle token decimals. - decimals: TANGLE_TOKEN_DECIMALS, + setAmount: onAmountChange, + decimals, min: minAmount, minErrorMessage, + max: maxAmount, + maxErrorMessage, }); - const handleChainChange = useCallback( - (newChain: LiquidStakingChain) => { - if (setChain !== undefined) { - setChain(newChain); - } - }, - [setChain], - ); + // Update the display amount when the amount prop changes. + // Only do this for controlled (read-only) inputs. + useEffect(() => { + if (amount !== null) { + updateDisplayAmountManual(amount); + } + }, [amount, updateDisplayAmountManual]); const isError = errorMessage !== null; @@ -95,10 +109,7 @@ const LiquidStakingInput: FC = ({ )} >
- + {rightElement}
@@ -116,11 +127,15 @@ const LiquidStakingInput: FC = ({ readOnly={isReadOnly} /> - +
- {errorMessage && ( + {errorMessage !== null && ( * {errorMessage} @@ -129,79 +144,55 @@ const LiquidStakingInput: FC = ({ ); }; -type TokenChipProps = { - token: LiquidStakingToken; - isLiquidVariant: boolean; -}; - -/** @internal */ -const TokenChip: FC = ({ token, isLiquidVariant }) => { - const chain = LS_TOKEN_TO_CHAIN[token]; - - return ( -
- - - - {isLiquidVariant && LIQUID_STAKING_TOKEN_PREFIX} - {token} - -
- ); -}; - type ChainSelectorProps = { - selectedChain: LiquidStakingChain; + selectedChainId: LiquidStakingChainId; /** * If this function is not provided, the selector will be * considered read-only. */ - setChain?: (newChain: LiquidStakingChain) => void; + setChain?: (newChain: LiquidStakingChainId) => void; }; -const ChainSelector: FC = ({ selectedChain, setChain }) => { +/** @internal */ +const ChainSelector: FC = ({ + selectedChainId, + setChain, +}) => { const isReadOnly = setChain === undefined; const base = ( -
- +
+
+ - - {LS_CHAIN_TO_NETWORK_NAME[selectedChain]} - + + {LS_CHAIN_TO_NETWORK_NAME[selectedChainId]} + +
- {!isReadOnly && } + {!isReadOnly && }
); return setChain !== undefined ? ( - - {base} - + {base}
    - {Object.values(LiquidStakingChain) - .filter((chain) => chain !== selectedChain) - .map((chain) => { + {Object.values(LiquidStakingChainId) + .filter((chainId) => chainId !== selectedChainId) + .map((chainId) => { return ( -
  • +
  • } - onSelect={() => setChain(chain)} + leftIcon={} + onSelect={() => setChain(chainId)} className="px-3 normal-case" > - {LS_CHAIN_TO_NETWORK_NAME[chain]} + {LS_CHAIN_TO_NETWORK_NAME[chainId]}
  • ); diff --git a/apps/tangle-dapp/components/LiquidStaking/LiquidStakingTokenItem.tsx b/apps/tangle-dapp/components/LiquidStaking/LiquidStakingTokenItem.tsx index e9fe1d433..9111f97d6 100644 --- a/apps/tangle-dapp/components/LiquidStaking/LiquidStakingTokenItem.tsx +++ b/apps/tangle-dapp/components/LiquidStaking/LiquidStakingTokenItem.tsx @@ -3,14 +3,13 @@ import { BN } from '@polkadot/util'; import { ArrowRight } from '@webb-tools/icons'; import { Button, Chip, Typography } from '@webb-tools/webb-ui-components'; -import assert from 'assert'; import Image from 'next/image'; import { FC, useMemo } from 'react'; import { StaticAssetPath } from '../../constants'; import { LIQUID_STAKING_TOKEN_PREFIX, - LiquidStakingChain, + LiquidStakingChainId, LiquidStakingToken, TVS_TOOLTIP, } from '../../constants/liquidStaking'; @@ -20,41 +19,25 @@ import ChainLogo from './ChainLogo'; import StatItem from './StatItem'; export type LiquidStakingTokenItemProps = { - chain: LiquidStakingChain; + chainId: LiquidStakingChainId; title: string; tokenSymbol: LiquidStakingToken; totalValueStaked: number; totalStaked: string; - - /** - * Annual Percentage Yield (APY). Should a decimal value - * between 0 and 1. - */ - annualPercentageYield: number; }; const LiquidStakingTokenItem: FC = ({ title, - chain, + chainId, tokenSymbol, totalValueStaked, - annualPercentageYield, totalStaked, }) => { - assert( - annualPercentageYield >= 0 && annualPercentageYield <= 1, - 'APY should be between 0 and 1', - ); - const formattedTotalValueStaked = totalValueStaked.toLocaleString('en-US', { style: 'currency', currency: 'USD', }); - const formattedAnnualPercentageYield = (annualPercentageYield * 100).toFixed( - 2, - ); - const formattedTotalStaked = useMemo( () => formatTangleBalance(new BN(totalStaked)), [totalStaked], @@ -64,7 +47,7 @@ const LiquidStakingTokenItem: FC = ({
    - + = ({
    - - { + const [isSelectTokenModalOpen, setIsSelectTokenModalOpen] = useState(false); + const [fromAmount, setFromAmount] = useState(null); + + const [isRequestSubmittedModalOpen, setIsRequestSubmittedModalOpen] = + useState(false); + + const [selectedChainId, setSelectedChainId] = useState( + LiquidStakingChainId.TANGLE_RESTAKING_PARACHAIN, + ); + + const { execute: executeRedeemTx, status: redeemTxStatus } = useRedeemTx(); + const { liquidBalances } = useParachainBalances(); + + const selectedChain = LIQUID_STAKING_CHAIN_MAP[selectedChainId]; + + const exchangeRate = useExchangeRate( + ExchangeRateType.LiquidToNative, + selectedChain.currency, + ); + + const { result: areAllDelegationsOccupiedOpt } = useDelegationsOccupiedStatus( + selectedChain.currency, + ); + + const areAllDelegationsOccupied = + areAllDelegationsOccupiedOpt === null + ? null + : areAllDelegationsOccupiedOpt.unwrapOrDefault(); + + const { result: minimumRedeemAmount } = useApiRx( + useCallback( + (api) => + api.query.lstMinting.minimumRedeem({ + Native: selectedChain.currency, + }), + [selectedChain.currency], + ), + TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint, + ); + + const { result: existentialDepositAmount } = useApi( + useCallback((api) => api.consts.balances.existentialDeposit, []), + TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint, + ); + + const minimumInputAmount = useMemo(() => { + if (minimumRedeemAmount === null || existentialDepositAmount === null) { + return null; + } + + return BN.max(minimumRedeemAmount, existentialDepositAmount); + }, [existentialDepositAmount, minimumRedeemAmount]); + + const maximumInputAmount = useMemo(() => { + if (liquidBalances === null) { + return null; + } + + return liquidBalances.get(selectedChain.token) ?? BN_ZERO; + }, [liquidBalances, selectedChain.token]); + + const handleUnstakeClick = useCallback(() => { + if (executeRedeemTx === null || fromAmount === null) { + return; + } + + executeRedeemTx({ + amount: fromAmount, + currency: selectedChain.currency, + }); + }, [executeRedeemTx, fromAmount, selectedChain.currency]); + + const toAmount = useMemo(() => { + if (fromAmount === null || exchangeRate === null) { + return null; + } + + return fromAmount.muln(exchangeRate); + }, [exchangeRate, fromAmount]); + + const handleTokenSelect = useCallback(() => { + setIsSelectTokenModalOpen(false); + }, []); + + const selectTokenModalOptions = useMemo(() => { + // TODO: Dummy data. + return [{ address: '0x123456' as any, amount: new BN(100), decimals: 18 }]; + }, []); + + // Open the request submitted modal when the redeem + // transaction is complete. + useEffect(() => { + if (redeemTxStatus === TxStatus.COMPLETE) { + setIsRequestSubmittedModalOpen(true); + } + }, [redeemTxStatus]); + + const stakedWalletBalance = ( + setFromAmount(maximumInputAmount)} + /> + ); + + return ( + <> + {/* TODO: Have a way to trigger a refresh of the amount once the wallet balance (max) button is clicked. Need to signal to the liquid staking input to update its display amount based on the `fromAmount` prop. */} + setIsSelectTokenModalOpen(true)} + /> + + + + + + {/* Details */} +
    + + + + + +
    + + {areAllDelegationsOccupied?.isTrue && ( + + )} + + + + setIsSelectTokenModalOpen(false)} + onTokenSelect={handleTokenSelect} + /> + + setIsRequestSubmittedModalOpen(false)} + unstakeRequest={null as any} + /> + + ); +}; + +export default LiquidUnstakeCard; diff --git a/apps/tangle-dapp/components/LiquidStaking/MintAndRedeemFeeDetailItem.tsx b/apps/tangle-dapp/components/LiquidStaking/MintAndRedeemFeeDetailItem.tsx new file mode 100644 index 000000000..ac56cf43b --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/MintAndRedeemFeeDetailItem.tsx @@ -0,0 +1,57 @@ +import { BN, BN_ZERO } from '@polkadot/util'; +import { TANGLE_TOKEN_DECIMALS } from '@webb-tools/dapp-config'; +import { SkeletonLoader } from '@webb-tools/webb-ui-components'; +import { FC, useMemo } from 'react'; + +import { EMPTY_VALUE_PLACEHOLDER } from '../../constants'; +import { LiquidStakingToken } from '../../constants/liquidStaking'; +import useMintAndRedeemFees from '../../data/liquidStaking/useMintAndRedeemFees'; +import formatBn from '../../utils/formatBn'; +import scaleAmountByPermill from '../../utils/scaleAmountByPermill'; +import DetailItem from './DetailItem'; + +export type MintAndRedeemFeeDetailItemProps = { + isMinting: boolean; + intendedAmount: BN | null; + token: LiquidStakingToken; +}; + +const MintAndRedeemFeeDetailItem: FC = ({ + isMinting, + intendedAmount, + token, +}) => { + const { result: fees } = useMintAndRedeemFees(); + const fee = fees === null ? null : isMinting ? fees.mintFee : fees.redeemFee; + + const feeAmount = useMemo(() => { + if (fee === null) { + return null; + } + + return scaleAmountByPermill(intendedAmount ?? BN_ZERO, fee); + }, [fee, intendedAmount]); + + const formattedFeeAmount = useMemo(() => { + if (fee === null) { + return EMPTY_VALUE_PLACEHOLDER; + } else if (feeAmount === null) { + return null; + } + + // TODO: What token is charged as fee? The same as the intended token? TNT? Depending on which one it is, use its corresponding decimals. + return formatBn(feeAmount, TANGLE_TOKEN_DECIMALS); + }, [fee, feeAmount]); + + const value = + formattedFeeAmount === null ? ( + + ) : ( + `${formattedFeeAmount} ${token}` + ); + + // TODO: Add proper tooltip content. + return ; +}; + +export default MintAndRedeemFeeDetailItem; diff --git a/apps/tangle-dapp/components/LiquidStaking/ModalIcon.tsx b/apps/tangle-dapp/components/LiquidStaking/ModalIcon.tsx new file mode 100644 index 000000000..f24813974 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/ModalIcon.tsx @@ -0,0 +1,42 @@ +import { IconBase } from '@webb-tools/icons/types'; +import { FC, ReactNode } from 'react'; +import { twMerge } from 'tailwind-merge'; + +export enum ModalIconCommonVariant { + SUCCESS, +} + +export type ModalIconProps = { + Icon: (props: IconBase) => ReactNode; + className?: string; + iconClassName?: string; + commonVariant?: ModalIconCommonVariant; +}; + +const ModalIcon: FC = ({ + Icon, + className, + iconClassName, + commonVariant, +}) => { + return ( +
    + +
    + ); +}; + +export default ModalIcon; diff --git a/apps/tangle-dapp/components/LiquidStaking/ParachainWalletBalance.tsx b/apps/tangle-dapp/components/LiquidStaking/ParachainWalletBalance.tsx new file mode 100644 index 000000000..8b8dbb44c --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/ParachainWalletBalance.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { BN_ZERO } from '@polkadot/util'; +import { WalletFillIcon, WalletLineIcon } from '@webb-tools/icons'; +import { + SkeletonLoader, + Tooltip, + TooltipBody, + TooltipTrigger, + Typography, +} from '@webb-tools/webb-ui-components'; +import { FC, useCallback, useMemo, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import { EMPTY_VALUE_PLACEHOLDER } from '../../constants'; +import { LiquidStakingToken } from '../../constants/liquidStaking'; +import useParachainBalances from '../../data/liquidStaking/useParachainBalances'; +import useSubstrateAddress from '../../hooks/useSubstrateAddress'; +import formatBn from '../../utils/formatBn'; + +export type ParachainWalletBalanceProps = { + isNative?: boolean; + token: LiquidStakingToken; + decimals: number; + tooltip?: string; + onlyShowTooltipWhenBalanceIsSet?: boolean; + onClick?: () => void; +}; + +const ParachainWalletBalance: FC = ({ + isNative = true, + token, + decimals, + tooltip, + onlyShowTooltipWhenBalanceIsSet = true, + onClick, +}) => { + const [isHovering, setIsHovering] = useState(false); + + const activeSubstrateAddress = useSubstrateAddress(); + const { nativeBalances, liquidBalances } = useParachainBalances(); + const map = isNative ? nativeBalances : liquidBalances; + + const balance = useMemo(() => { + if (map === null) { + return null; + } + + return map.get(token) ?? BN_ZERO; + }, [map, token]); + + const formattedBalance = useMemo(() => { + // No account is active. + if (activeSubstrateAddress === null) { + return EMPTY_VALUE_PLACEHOLDER; + } + // Balance is still loading. + else if (balance === null) { + return null; + } + + return formatBn(balance, decimals, { + fractionMaxLength: undefined, + includeCommas: true, + }); + }, [activeSubstrateAddress, balance, decimals]); + + const isClickable = + onlyShowTooltipWhenBalanceIsSet && balance !== null && !balance.isZero(); + + const handleClick = useCallback(() => { + if (!isClickable || onClick === undefined) { + return; + } + + onClick(); + }, [isClickable, onClick]); + + const content = ( +
    setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className={twMerge( + 'group flex gap-1 items-center justify-center', + isClickable && 'cursor-pointer', + )} + > + {isHovering && isClickable ? ( + + ) : ( + + )} + + {formattedBalance === null ? ( + + ) : ( + + {formattedBalance} + + )} +
    + ); + + if (tooltip === undefined || !isClickable) { + return content; + } + + // Otherwise, the tooltip is set and it should be shown. + return ( + + {content} + + + {tooltip} + + + ); +}; + +export default ParachainWalletBalance; diff --git a/apps/tangle-dapp/components/LiquidStaking/SelectTokenModal.tsx b/apps/tangle-dapp/components/LiquidStaking/SelectTokenModal.tsx new file mode 100644 index 000000000..716c3419e --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/SelectTokenModal.tsx @@ -0,0 +1,134 @@ +import { BN } from '@polkadot/util'; +import { + GITHUB_BUG_REPORT_URL, + Modal, + ModalContent, + ModalHeader, + Typography, +} from '@webb-tools/webb-ui-components'; +import { FC, useEffect, useMemo } from 'react'; + +import { LiquidStakingChainId } from '../../constants/liquidStaking'; +import { AnySubstrateAddress } from '../../types/utils'; +import formatBn from '../../utils/formatBn'; +import AddressLink from './AddressLink'; +import ChainLogo from './ChainLogo'; + +export type SelectTokenModalProps = { + isOpen: boolean; + options: Omit[]; + onTokenSelect: (address: AnySubstrateAddress) => void; + onClose: () => void; +}; + +const SelectTokenModal: FC = ({ + isOpen, + options, + onTokenSelect, + onClose, +}) => { + // Sanity check: Ensure all addresses are unique. + useEffect(() => { + const seenAddresses = new Set(); + + for (const option of options) { + if (seenAddresses.has(option.address)) { + console.warn( + `Duplicate token address found: ${option.address}, expected all addresses to be unique`, + ); + } + } + }, [options]); + + return ( + + + + Select Token + + +
    + {options.map((option) => { + return ( + + ); + })} + + {/* No tokens available */} + {options.length === 0 && ( +
    + + No tokens available + + + + Think this is a bug?{' '} + + Report it here + + +
    + )} +
    +
    +
    + ); +}; + +export type TokenListItemProps = { + address: AnySubstrateAddress; + decimals: number; + amount: BN; + onClick: () => void; +}; + +/** @internal */ +const TokenListItem: FC = ({ + address, + decimals, + amount, + onClick, +}) => { + const formattedAmount = useMemo(() => { + return formatBn(amount, decimals); + }, [amount, decimals]); + + return ( +
    + {/* Information */} +
    + + +
    + + tgDOT_A + + + +
    +
    + + {/* Amount */} + + {formattedAmount} + +
    + ); +}; + +export default SelectTokenModal; diff --git a/apps/tangle-dapp/components/LiquidStaking/SelectValidators.tsx b/apps/tangle-dapp/components/LiquidStaking/SelectValidators.tsx deleted file mode 100644 index 0b62e4b15..000000000 --- a/apps/tangle-dapp/components/LiquidStaking/SelectValidators.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ChevronDown, SettingsFillIcon } from '@webb-tools/icons'; -import { Typography } from '@webb-tools/webb-ui-components'; -import { FC } from 'react'; - -import HoverButtonStyle from './HoverButtonStyle'; - -const SelectValidators: FC = () => { - return ( - -
    - - Validators - - - -
    -
    - ); -}; - -export default SelectValidators; diff --git a/apps/tangle-dapp/components/LiquidStaking/SelectValidatorsButton.tsx b/apps/tangle-dapp/components/LiquidStaking/SelectValidatorsButton.tsx new file mode 100644 index 000000000..06d1cd18d --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/SelectValidatorsButton.tsx @@ -0,0 +1,24 @@ +import { SettingsFillIcon } from '@webb-tools/icons'; +import { Typography } from '@webb-tools/webb-ui-components'; +import { FC } from 'react'; + +import DropdownChevronIcon from './DropdownChevronIcon'; + +const SelectValidatorsButton: FC = () => { + return ( +
    + + {' '} + Validators + + + +
    + ); +}; + +export default SelectValidatorsButton; diff --git a/apps/tangle-dapp/components/LiquidStaking/StakedAssetsTable.tsx b/apps/tangle-dapp/components/LiquidStaking/StakedAssetsTable.tsx new file mode 100644 index 000000000..3080e8e86 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/StakedAssetsTable.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { BN } from '@polkadot/util'; +import { HexString } from '@polkadot/util/types'; +import { + createColumnHelper, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { InformationLine } from '@webb-tools/icons'; +import { + Avatar, + AvatarGroup, + fuzzyFilter, + Table, + Typography, +} from '@webb-tools/webb-ui-components'; +import { FC } from 'react'; + +import { AnySubstrateAddress } from '../../types/utils'; +import GlassCard from '../GlassCard'; +import { HeaderCell } from '../tableCells'; +import TokenAmountCell from '../tableCells/TokenAmountCell'; +import AddressLink from './AddressLink'; + +type StakedAssetItem = { + id: HexString; + validators: AnySubstrateAddress[]; + amount: BN; +}; + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor('id', { + header: () => , + cell: (props) => { + return ; + }, + }), + columnHelper.accessor('validators', { + header: () => ( + + ), + cell: (props) => { + return ( + + {props.getValue().map((address, index) => ( + + ))} + + ); + }, + }), + columnHelper.accessor('amount', { + header: () => , + cell: (props) => { + return ; + }, + }), +]; + +const StakedAssetsTable: FC = () => { + // TODO: Mock data. + const testAddresses = [ + '0x3a7f9e8c14b7d2f5', + '0xd5c4a2b1f3e8c7d9', + ] as AnySubstrateAddress[]; + + const data: StakedAssetItem[] = [ + { + id: '0x3a7f9e8c14b7d2f5', + validators: testAddresses, + amount: new BN(100), + }, + { + id: '0xd5c4a2b1f3e8c7d9', + validators: testAddresses, + amount: new BN(123), + }, + { + id: '0x9b3e47d8a5c2f1e4', + validators: testAddresses, + amount: new BN(321), + }, + ]; + + const table = useReactTable({ + data, + columns, + filterFns: { + fuzzy: fuzzyFilter, + }, + globalFilterFn: fuzzyFilter, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( + + + Staked Assets + + +
    + + + +
    + + + + Select the token to unstake to receive 'Unstake NFT' + representing your assets. Redeem after the unbonding period to claim + funds.{' '} + + Learn More + + +
    + + ); +}; + +export default StakedAssetsTable; diff --git a/apps/tangle-dapp/components/LiquidStaking/TokenChip.tsx b/apps/tangle-dapp/components/LiquidStaking/TokenChip.tsx new file mode 100644 index 000000000..7ec200ca5 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/TokenChip.tsx @@ -0,0 +1,61 @@ +import { Typography } from '@webb-tools/webb-ui-components'; +import assert from 'assert'; +import { FC } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import { + LIQUID_STAKING_CHAINS, + LIQUID_STAKING_TOKEN_PREFIX, + LiquidStakingToken, +} from '../../constants/liquidStaking'; +import ChainLogo from './ChainLogo'; +import DropdownChevronIcon from './DropdownChevronIcon'; + +type TokenChipProps = { + token?: LiquidStakingToken; + isLiquidVariant: boolean; + onClick?: () => void; +}; + +const TokenChip: FC = ({ token, isLiquidVariant, onClick }) => { + const chain = (() => { + if (token === undefined) { + return null; + } + + const result = LIQUID_STAKING_CHAINS.find((chain) => chain.token === token); + + assert( + result !== undefined, + 'All tokens should have a corresponding chain', + ); + + return result; + })(); + + return ( +
    + + + + {isLiquidVariant && LIQUID_STAKING_TOKEN_PREFIX} + {token} + + + {onClick !== undefined && } +
    + ); +}; + +export default TokenChip; diff --git a/apps/tangle-dapp/components/LiquidStaking/TokenInfoCard.tsx b/apps/tangle-dapp/components/LiquidStaking/TokenInfoCard.tsx deleted file mode 100644 index c892c7361..000000000 --- a/apps/tangle-dapp/components/LiquidStaking/TokenInfoCard.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { - ArrowRightUp, - ExternalLinkLine, - InformationLine, -} from '@webb-tools/icons'; -import { - Button, - IconWithTooltip, - Typography, -} from '@webb-tools/webb-ui-components'; -import React, { FC } from 'react'; - -import { - LIQUID_STAKING_TOKEN_PREFIX, - LiquidStakingToken, -} from '../../constants/liquidStaking'; -import useLSTokenSVGs from '../../hooks/useLSTokenSVGs'; - -type TokenInfoProps = { - title: string; - tooltip?: string; - value: string; - valueTooltip?: string; -}; - -type TokenInfoCardProps = { - stakingInfo: TokenInfoProps; - availableInfo: TokenInfoProps; - unstakingInfo: TokenInfoProps; - apyInfo: TokenInfoProps; - tokenSymbol: string; - tokenSVG?: React.FC>; -}; - -const TokenInfoCard = ({ - stakingInfo, - availableInfo, - unstakingInfo, - apyInfo, - tokenSymbol, -}: TokenInfoCardProps) => { - const TokenSVG = useLSTokenSVGs(tokenSymbol as LiquidStakingToken); - - return ( -
    -
    -
    - - - - - - - -
    - -
    - - - -
    -
    - -
    - {TokenSVG && } -
    -
    - ); -}; - -type GridItemProps = { - title: string; - tooltip?: string; - value: string; - valueTooltip?: string; - tokenSymbol?: string; - fw?: 'normal' | 'bold'; -}; - -/** @internal */ -const GridItem: FC = ({ - title, - tooltip, - value, - valueTooltip, - tokenSymbol, - fw, -}) => { - return ( -
    -
    - - {title} - - - {tooltip !== undefined && ( - - } - content={tooltip} - overrideTooltipBodyProps={{ - className: 'max-w-[350px]', - }} - /> - )} -
    - -
    - - {value} - - - - {tokenSymbol} - - - {valueTooltip !== undefined && ( - - } - content={valueTooltip} - overrideTooltipBodyProps={{ - className: 'max-w-[350px]', - }} - /> - )} -
    -
    - ); -}; - -export default TokenInfoCard; diff --git a/apps/tangle-dapp/components/LiquidStaking/UnstakePeriodDetailItem.tsx b/apps/tangle-dapp/components/LiquidStaking/UnstakePeriodDetailItem.tsx new file mode 100644 index 000000000..34bf6998b --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/UnstakePeriodDetailItem.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react'; + +import DetailItem from './DetailItem'; + +const UnstakePeriodDetailItem: FC = () => { + // TODO: Load this info from the chain. Currently using dummy data. + return ( + + 7 days + + } + /> + ); +}; + +export default UnstakePeriodDetailItem; diff --git a/apps/tangle-dapp/components/LiquidStaking/UnstakeRequestSubmittedModal.tsx b/apps/tangle-dapp/components/LiquidStaking/UnstakeRequestSubmittedModal.tsx new file mode 100644 index 000000000..1bb2b39e1 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/UnstakeRequestSubmittedModal.tsx @@ -0,0 +1,77 @@ +import { CheckboxCircleLine, WalletLineIcon } from '@webb-tools/icons'; +import { + Button, + Modal, + ModalContent, + ModalFooter, + ModalHeader, + Typography, +} from '@webb-tools/webb-ui-components'; +import { FC, useCallback } from 'react'; + +import ExternalLink from './ExternalLink'; +import ModalIcon, { ModalIconCommonVariant } from './ModalIcon'; +import { UnstakeRequestItem } from './UnstakeRequestsTable'; + +export type UnstakeRequestSubmittedModalProps = { + isOpen: boolean; + unstakeRequest: UnstakeRequestItem; + onClose: () => void; +}; + +const UnstakeRequestSubmittedModal: FC = ({ + isOpen, + // TODO: Make use of the unstake request data, which is relevant for the link's href. + unstakeRequest: _unstakeRequest, + onClose, +}) => { + const handleAddTokenToWallet = useCallback(() => { + // TODO: Handle this case. + }, []); + + return ( + + + + Unstake Request Submitted + + +
    + + + + After the schedule period completes, you can withdraw unstaked + tokens. + + + {/* TODO: External link's href. */} + Learn More +
    + + + + +
    +
    + ); +}; + +export default UnstakeRequestSubmittedModal; diff --git a/apps/tangle-dapp/components/LiquidStaking/UnstakeRequestsTable.tsx b/apps/tangle-dapp/components/LiquidStaking/UnstakeRequestsTable.tsx new file mode 100644 index 000000000..08309fe21 --- /dev/null +++ b/apps/tangle-dapp/components/LiquidStaking/UnstakeRequestsTable.tsx @@ -0,0 +1,230 @@ +import { + createColumnHelper, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { + ArrowRightUp, + CheckboxCircleFill, + Close, + TimeFillIcon, + WalletLineIcon, +} from '@webb-tools/icons'; +import { IconBase } from '@webb-tools/icons/types'; +import { + CheckBox, + fuzzyFilter, + IconButton, + Table, + TANGLE_DOCS_URL, + Typography, +} from '@webb-tools/webb-ui-components'; +import BN from 'bn.js'; +import { FC, ReactNode } from 'react'; + +import { AnySubstrateAddress } from '../../types/utils'; +import calculateTimeRemaining from '../../utils/calculateTimeRemaining'; +import GlassCard from '../GlassCard'; +import { HeaderCell } from '../tableCells'; +import TokenAmountCell from '../tableCells/TokenAmountCell'; +import AddressLink from './AddressLink'; +import CancelUnstakeModal from './CancelUnstakeModal'; +import ExternalLink from './ExternalLink'; + +export type UnstakeRequestItem = { + address: AnySubstrateAddress; + amount: BN; + endTimestamp?: number; +}; + +const columnHelper = createColumnHelper(); + +const columns = [ + columnHelper.accessor('address', { + header: () => ( + + ), + cell: (props) => { + return ( +
    + void 0} + wrapperClassName="pt-0.5" + /> + + +
    + ); + }, + }), + columnHelper.accessor('endTimestamp', { + header: () => , + cell: (props) => { + const endTimestamp = props.getValue(); + + const timeRemaining = + endTimestamp === undefined + ? undefined + : calculateTimeRemaining(new Date(endTimestamp)); + + const content = + timeRemaining === undefined ? ( + + ) : ( +
    + {timeRemaining} +
    + ); + + return
    {content}
    ; + }, + }), + columnHelper.accessor('amount', { + header: () => , + cell: (props) => { + return ; + }, + }), + columnHelper.display({ + id: 'actions', + header: () => , + cell: (_props) => { + return ( +
    + {/* TODO: Implement onClick. */} + void 0} + /> + + {/* TODO: Implement onClick. */} + void 0} + /> +
    + ); + }, + enableSorting: false, + }), +]; + +const UnstakeRequestsTable: FC = () => { + // TODO: Mock data. + const data: UnstakeRequestItem[] = [ + { + address: '0x123456' as any, + amount: new BN(100), + endTimestamp: Date.now() + 1000 * 60 * 60 * 24, + }, + { + address: '0x123456' as any, + amount: new BN(100), + endTimestamp: Date.now() + 1000 * 60 * 60 * 24, + }, + { + address: '0x123456' as any, + amount: new BN(100), + endTimestamp: Date.now() + 1000 * 60 * 60 * 24, + }, + { + address: '0x123456' as any, + amount: new BN(100), + endTimestamp: Date.now() + 1000 * 60 * 60 * 24, + }, + ]; + + const table = useReactTable({ + data, + columns, + filterFns: { + fuzzy: fuzzyFilter, + }, + globalFilterFn: fuzzyFilter, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
    + + {data.length === 0 ? ( + + ) : ( +
    + )} + + + {data.length === 0 && ( +
    + + View Docs + +
    + )} + + {/* TODO: Handle this modal properly. */} + void 0} + unstakeRequest={null as any} + /> + + ); +}; + +/** @internal */ +const NoUnstakeRequestsNotice: FC = () => { + return ( +
    + + No unstake requests + + + + You will be able to claim your tokens after the unstake request has been + processed. To unstake your tokens go to the unstake tab to schedule + request. + +
    + ); +}; + +export type UtilityIconButtonProps = { + tooltip: string; + Icon: (props: IconBase) => ReactNode; + onClick: () => void; +}; + +/** @internal */ +const UtilityIconButton: FC = ({ + tooltip, + Icon, + onClick, +}) => { + return ( + + + + ); +}; + +export default UnstakeRequestsTable; diff --git a/apps/tangle-dapp/components/LiquidStaking/WalletBalance.tsx b/apps/tangle-dapp/components/LiquidStaking/WalletBalance.tsx deleted file mode 100644 index 79ea5125b..000000000 --- a/apps/tangle-dapp/components/LiquidStaking/WalletBalance.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { WalletLineIcon } from '@webb-tools/icons'; -import { Typography } from '@webb-tools/webb-ui-components'; -import { FC } from 'react'; - -const WalletBalance: FC = () => { - return ( - - 0.00 - - ); -}; - -export default WalletBalance; diff --git a/apps/tangle-dapp/components/NetworkSelector/NetworkSelectionButton.tsx b/apps/tangle-dapp/components/NetworkSelector/NetworkSelectionButton.tsx index 7bd971170..0a147bb33 100644 --- a/apps/tangle-dapp/components/NetworkSelector/NetworkSelectionButton.tsx +++ b/apps/tangle-dapp/components/NetworkSelector/NetworkSelectionButton.tsx @@ -92,7 +92,7 @@ const NetworkSelectionButton: FC = () => { - + Network can't be changed while you're in this page. diff --git a/apps/tangle-dapp/components/RestakeDetailCard/utils.ts b/apps/tangle-dapp/components/RestakeDetailCard/utils.ts index bcbd9ea5d..5b89067f4 100644 --- a/apps/tangle-dapp/components/RestakeDetailCard/utils.ts +++ b/apps/tangle-dapp/components/RestakeDetailCard/utils.ts @@ -1,3 +1,5 @@ +import { EMPTY_VALUE_PLACEHOLDER } from '../../constants'; + export function getDisplayValue(val?: string | number): string { if (typeof val === 'string') { return val; @@ -7,5 +9,5 @@ export function getDisplayValue(val?: string | number): string { return val.toLocaleString('en-US'); } - return '--'; + return EMPTY_VALUE_PLACEHOLDER; } diff --git a/apps/tangle-dapp/components/Sidebar/sidebarProps.ts b/apps/tangle-dapp/components/Sidebar/sidebarProps.ts index 14e949031..df47c38d7 100644 --- a/apps/tangle-dapp/components/Sidebar/sidebarProps.ts +++ b/apps/tangle-dapp/components/Sidebar/sidebarProps.ts @@ -75,7 +75,7 @@ const SIDEBAR_STATIC_ITEMS: SideBarItemProps[] = [ subItems: [], }, { - name: 'Claim', + name: 'Claim Airdrop', href: PagePath.CLAIM_AIRDROP, isInternal: true, isNext: true, diff --git a/apps/tangle-dapp/components/UnbondingStatsItem/UnbondingStatsItem.tsx b/apps/tangle-dapp/components/UnbondingStatsItem/UnbondingStatsItem.tsx index 9790e5e23..ae172165e 100644 --- a/apps/tangle-dapp/components/UnbondingStatsItem/UnbondingStatsItem.tsx +++ b/apps/tangle-dapp/components/UnbondingStatsItem/UnbondingStatsItem.tsx @@ -8,7 +8,7 @@ import { EMPTY_VALUE_PLACEHOLDER } from '../../constants'; import useNetworkStore from '../../context/useNetworkStore'; import useUnbondingAmount from '../../data/NominatorStats/useUnbondingAmount'; import useUnbonding from '../../data/staking/useUnbonding'; -import { formatBnWithCommas } from '../../utils/formatBnWithCommas'; +import addCommasToNumber from '../../utils/addCommasToNumber'; import formatTangleBalance from '../../utils/formatTangleBalance'; import { NominatorStatsItem } from '../NominatorStatsItem'; @@ -41,7 +41,7 @@ const UnbondingStatsItem: FC = () => {

    {entry.remainingEras.gtn(0) && ( -

    {formatBnWithCommas(entry.remainingEras)} eras remaining.

    +

    {addCommasToNumber(entry.remainingEras)} eras remaining.

    )} ); diff --git a/apps/tangle-dapp/components/account/WithdrawEvmBalanceAction.tsx b/apps/tangle-dapp/components/account/WithdrawEvmBalanceAction.tsx index b1c468846..275e0f175 100644 --- a/apps/tangle-dapp/components/account/WithdrawEvmBalanceAction.tsx +++ b/apps/tangle-dapp/components/account/WithdrawEvmBalanceAction.tsx @@ -39,6 +39,10 @@ const WithdrawEvmBalanceAction: FC = () => { const { execute, status } = useEvmBalanceWithdrawTx(tokenAmountStr); const handleWithdraw = useCallback(async () => { + if (execute === null) { + return; + } + await execute({ pendingEvmBalance, evmAddress20, diff --git a/apps/tangle-dapp/components/tableCells/TokenAmountCell.tsx b/apps/tangle-dapp/components/tableCells/TokenAmountCell.tsx index c4da5232e..cf3b45e63 100644 --- a/apps/tangle-dapp/components/tableCells/TokenAmountCell.tsx +++ b/apps/tangle-dapp/components/tableCells/TokenAmountCell.tsx @@ -1,18 +1,34 @@ import { BN } from '@polkadot/util'; -import { FC } from 'react'; +import { FC, useMemo } from 'react'; import { twMerge } from 'tailwind-merge'; import useNetworkStore from '../../context/useNetworkStore'; +import formatBn from '../../utils/formatBn'; import formatTangleBalance from '../../utils/formatTangleBalance'; export type TokenAmountCellProps = { amount: BN; className?: string; + tokenSymbol?: string; + decimals?: number; }; -const TokenAmountCell: FC = ({ amount, className }) => { +const TokenAmountCell: FC = ({ + amount, + className, + tokenSymbol, + decimals, +}) => { const { nativeTokenSymbol } = useNetworkStore(); - const formattedBalance = formatTangleBalance(amount); + + const formattedBalance = useMemo(() => { + // Default to Tangle decimals if not provided. + if (decimals === undefined) { + return formatTangleBalance(amount); + } + + return formatBn(amount, decimals); + }, [amount, decimals]); const parts = formattedBalance.split('.'); const integerPart = parts[0]; @@ -28,7 +44,8 @@ const TokenAmountCell: FC = ({ amount, className }) => { {integerPart} - {decimalPart !== undefined && `.${decimalPart}`} {nativeTokenSymbol} + {decimalPart !== undefined && `.${decimalPart}`}{' '} + {tokenSymbol ?? nativeTokenSymbol} ); diff --git a/apps/tangle-dapp/constants/liquidStaking.ts b/apps/tangle-dapp/constants/liquidStaking.ts index 151f408a1..d3ba1bd45 100644 --- a/apps/tangle-dapp/constants/liquidStaking.ts +++ b/apps/tangle-dapp/constants/liquidStaking.ts @@ -1,8 +1,12 @@ -import { TanglePrimitivesCurrencyTokenSymbol } from '@polkadot/types/lookup'; +import { + TanglePrimitivesCurrencyTokenSymbol, + TanglePrimitivesTimeUnit, +} from '@polkadot/types/lookup'; +import { TANGLE_TOKEN_DECIMALS } from '@webb-tools/dapp-config'; import { StaticAssetPath } from '.'; -export enum LiquidStakingChain { +export enum LiquidStakingChainId { POLKADOT = 'Polkadot', PHALA = 'Phala', MOONBEAM = 'Moonbeam', @@ -17,66 +21,125 @@ export enum LiquidStakingToken { MANTA = 'MANTA', ASTAR = 'ASTAR', PHALA = 'PHALA', - TNT = 'BNC', + TNT = 'TNT', } -export const LS_CHAIN_TO_TOKEN: Record = - { - [LiquidStakingChain.POLKADOT]: LiquidStakingToken.DOT, - [LiquidStakingChain.PHALA]: LiquidStakingToken.PHALA, - [LiquidStakingChain.MOONBEAM]: LiquidStakingToken.GLMR, - [LiquidStakingChain.ASTAR]: LiquidStakingToken.ASTAR, - [LiquidStakingChain.MANTA]: LiquidStakingToken.MANTA, - [LiquidStakingChain.TANGLE_RESTAKING_PARACHAIN]: LiquidStakingToken.TNT, - }; - -export const LS_TOKEN_TO_CHAIN: Record = - { - [LiquidStakingToken.DOT]: LiquidStakingChain.POLKADOT, - [LiquidStakingToken.PHALA]: LiquidStakingChain.PHALA, - [LiquidStakingToken.GLMR]: LiquidStakingChain.MOONBEAM, - [LiquidStakingToken.ASTAR]: LiquidStakingChain.ASTAR, - [LiquidStakingToken.MANTA]: LiquidStakingChain.MANTA, - [LiquidStakingToken.TNT]: LiquidStakingChain.TANGLE_RESTAKING_PARACHAIN, - }; - -export const LS_CHAIN_TO_LOGO: Record = { - [LiquidStakingChain.POLKADOT]: StaticAssetPath.LIQUID_STAKING_TOKEN_POLKADOT, - [LiquidStakingChain.PHALA]: StaticAssetPath.LIQUID_STAKING_TOKEN_PHALA, - [LiquidStakingChain.MOONBEAM]: StaticAssetPath.LIQUID_STAKING_TOKEN_GLIMMER, - [LiquidStakingChain.ASTAR]: StaticAssetPath.LIQUID_STAKING_TOKEN_ASTAR, - [LiquidStakingChain.MANTA]: StaticAssetPath.LIQUID_STAKING_TOKEN_MANTA, - [LiquidStakingChain.TANGLE_RESTAKING_PARACHAIN]: - StaticAssetPath.LIQUID_STAKING_TANGLE_LOGO, +// TODO: Temporary manual override until the Parachain types are updated. +export type LiquidStakingCurrency = + | Exclude + | 'Tnt'; + +export type LiquidStakingChainDef = { + id: LiquidStakingChainId; + name: string; + token: LiquidStakingToken; + logo: StaticAssetPath; + networkName: string; + currency: LiquidStakingCurrency; + decimals: number; }; -// TODO: Instead of mapping to names, map to network/chain definitions themselves. This avoids redundancy and relies on a centralized definition for the network/chain which is better, since it simplifies future refactoring. -export const LS_CHAIN_TO_NETWORK_NAME: Record = { - [LiquidStakingChain.POLKADOT]: 'Polkadot Mainnet', - [LiquidStakingChain.PHALA]: 'Phala', - [LiquidStakingChain.MOONBEAM]: 'Moonbeam', - [LiquidStakingChain.ASTAR]: 'Astar', - [LiquidStakingChain.MANTA]: 'Manta', - [LiquidStakingChain.TANGLE_RESTAKING_PARACHAIN]: 'Tangle Parachain', +const POLKADOT: LiquidStakingChainDef = { + id: LiquidStakingChainId.POLKADOT, + name: 'Polkadot', + token: LiquidStakingToken.DOT, + logo: StaticAssetPath.LIQUID_STAKING_TOKEN_POLKADOT, + networkName: 'Polkadot Mainnet', + currency: 'Dot', + decimals: 10, }; -export const LS_TOKEN_TO_CURRENCY: Record< - LiquidStakingToken, - TanglePrimitivesCurrencyTokenSymbol['type'] -> = { - [LiquidStakingToken.DOT]: 'Dot', - [LiquidStakingToken.PHALA]: 'Pha', +const PHALA: LiquidStakingChainDef = { + id: LiquidStakingChainId.PHALA, + name: 'Phala', + token: LiquidStakingToken.PHALA, + logo: StaticAssetPath.LIQUID_STAKING_TOKEN_PHALA, + networkName: 'Phala', + currency: 'Pha', + decimals: 18, +}; + +const MOONBEAM: LiquidStakingChainDef = { + id: LiquidStakingChainId.MOONBEAM, + name: 'Moonbeam', + token: LiquidStakingToken.GLMR, + logo: StaticAssetPath.LIQUID_STAKING_TOKEN_GLIMMER, + networkName: 'Moonbeam', // TODO: No currency entry for GLMR in the Tangle Primitives? - [LiquidStakingToken.GLMR]: 'Dot', + currency: 'Dot', + decimals: 18, +}; + +const ASTAR: LiquidStakingChainDef = { + id: LiquidStakingChainId.ASTAR, + name: 'Astar', + token: LiquidStakingToken.ASTAR, + logo: StaticAssetPath.LIQUID_STAKING_TOKEN_ASTAR, + networkName: 'Astar', + // TODO: No currency entry for ASTAR in the Tangle Primitives? + currency: 'Dot', + decimals: 18, +}; + +const MANTA: LiquidStakingChainDef = { + id: LiquidStakingChainId.MANTA, + name: 'Manta', + token: LiquidStakingToken.MANTA, + logo: StaticAssetPath.LIQUID_STAKING_TOKEN_MANTA, + networkName: 'Manta', // TODO: No currency entry for ASTAR in the Tangle Primitives? - [LiquidStakingToken.ASTAR]: 'Dot', - // TODO: No currency entry for MANTA in the Tangle Primitives? - [LiquidStakingToken.MANTA]: 'Dot', - // TODO: This is temporary until the Tangle Primitives are updated with the correct currency token symbol for TNT. - [LiquidStakingToken.TNT]: 'Bnc', + currency: 'Dot', + decimals: 18, +}; + +const TANGLE_RESTAKING_PARACHAIN: LiquidStakingChainDef = { + id: LiquidStakingChainId.TANGLE_RESTAKING_PARACHAIN, + name: 'Tangle Parachain', + token: LiquidStakingToken.TNT, + logo: StaticAssetPath.LIQUID_STAKING_TANGLE_LOGO, + networkName: 'Tangle Parachain', + currency: 'Tnt', + decimals: TANGLE_TOKEN_DECIMALS, +}; + +export const LIQUID_STAKING_CHAIN_MAP: Record< + LiquidStakingChainId, + LiquidStakingChainDef +> = { + [LiquidStakingChainId.POLKADOT]: POLKADOT, + [LiquidStakingChainId.PHALA]: PHALA, + [LiquidStakingChainId.MOONBEAM]: MOONBEAM, + [LiquidStakingChainId.ASTAR]: ASTAR, + [LiquidStakingChainId.MANTA]: MANTA, + [LiquidStakingChainId.TANGLE_RESTAKING_PARACHAIN]: TANGLE_RESTAKING_PARACHAIN, +}; + +export const LIQUID_STAKING_CHAINS: LiquidStakingChainDef[] = Object.values( + LIQUID_STAKING_CHAIN_MAP, +); + +// TODO: Instead of mapping to names, map to network/chain definitions themselves. This avoids redundancy and relies on a centralized definition for the network/chain which is better, since it simplifies future refactoring. +export const LS_CHAIN_TO_NETWORK_NAME: Record = { + [LiquidStakingChainId.POLKADOT]: 'Polkadot Mainnet', + [LiquidStakingChainId.PHALA]: 'Phala', + [LiquidStakingChainId.MOONBEAM]: 'Moonbeam', + [LiquidStakingChainId.ASTAR]: 'Astar', + [LiquidStakingChainId.MANTA]: 'Manta', + [LiquidStakingChainId.TANGLE_RESTAKING_PARACHAIN]: 'Tangle Parachain', }; export const TVS_TOOLTIP = "Total Value Staked (TVS) refers to the total value of assets that are currently staked for this network in fiat currency. Generally used as an indicator of a network's security and trustworthiness."; export const LIQUID_STAKING_TOKEN_PREFIX = 'tg'; + +export type LiquidStakingCurrencyKey = + | { lst: LiquidStakingCurrency } + | { Native: LiquidStakingCurrency }; + +export type LiquidStakingTimeUnit = TanglePrimitivesTimeUnit['type']; + +export type LiquidStakingTimeUnitInstance = { + value: number; + unit: LiquidStakingTimeUnit; +}; diff --git a/apps/tangle-dapp/data/liquidStaking/getValueOfTangleCurrency.ts b/apps/tangle-dapp/data/liquidStaking/getValueOfTangleCurrency.ts new file mode 100644 index 000000000..c0911b72d --- /dev/null +++ b/apps/tangle-dapp/data/liquidStaking/getValueOfTangleCurrency.ts @@ -0,0 +1,53 @@ +// This will override global types and provide type definitions for +// Tangle Restaking Parachain for this file only. +import '@webb-tools/tangle-restaking-types'; + +import { TanglePrimitivesCurrencyCurrencyId } from '@polkadot/types/lookup'; + +import { LiquidStakingCurrency } from '../../constants/liquidStaking'; + +const getValueOfTangleCurrency = ( + tangleCurrencyId: TanglePrimitivesCurrencyCurrencyId, +): LiquidStakingCurrency => { + // TODO: Implement. + // Unfortunately, there doesn't seem to be a cleaner way of + // going about this. This is a direct cause of the way that + // Rust enums are generated to be used in TypeScript/JavaScript. + // if (tangleCurrencyId.isNative) { + // return tangleCurrencyId.asNative.type; + // } else if (tangleCurrencyId.isToken) { + // return tangleCurrencyId.asToken.type; + // } else if (tangleCurrencyId.isToken2) { + // return tangleCurrencyId.asToken2.type; + // } else if (tangleCurrencyId.isLstToken) { + // return tangleCurrencyId.asLst.type; + // } else if (tangleCurrencyId.isLst2) { + // return tangleCurrencyId.asLst2.type; + // } else if (tangleCurrencyId.isStable) { + // return tangleCurrencyId.asStable.type; + // } else if (tangleCurrencyId.isVsToken) { + // return tangleCurrencyId.asVsToken.type; + // } else if (tangleCurrencyId.isVsToken2) { + // return tangleCurrencyId.asVsToken2.type; + // } else if (tangleCurrencyId.isVsBond) { + // return tangleCurrencyId.asVsBond.type; + // } else if (tangleCurrencyId.isVsBond2) { + // return tangleCurrencyId.asVsBond2.type; + // } else if (tangleCurrencyId.isLpToken) { + // return tangleCurrencyId.asLpToken.type; + // } else if (tangleCurrencyId.isForeignAsset) { + // return tangleCurrencyId.asForeignAsset.type; + // } else if (tangleCurrencyId.isStableLpToken) { + // return tangleCurrencyId.asStableLpToken.type; + // } else if (tangleCurrencyId.isBlp) { + // return tangleCurrencyId.asBlp.type; + // } else if (tangleCurrencyId.isLend) { + // return tangleCurrencyId.asLend.type; + // } + + throw new Error( + `Unknown or unsupported currency type: ${tangleCurrencyId} (was the Tangle Restaking Parachain updated?)`, + ); +}; + +export default getValueOfTangleCurrency; diff --git a/apps/tangle-dapp/data/liquidStaking/useDelegationsOccupiedStatus.ts b/apps/tangle-dapp/data/liquidStaking/useDelegationsOccupiedStatus.ts new file mode 100644 index 000000000..4c9676a2d --- /dev/null +++ b/apps/tangle-dapp/data/liquidStaking/useDelegationsOccupiedStatus.ts @@ -0,0 +1,13 @@ +import { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK } from '@webb-tools/webb-ui-components/constants/networks'; + +import { LiquidStakingCurrency } from '../../constants/liquidStaking'; +import useApiRx from '../../hooks/useApiRx'; + +// TODO: Do a bit more research on what this signifies and means. Currently, it is only known that this is a requirement/check that may prevent further redeeming. +const useDelegationsOccupiedStatus = (currency: LiquidStakingCurrency) => { + return useApiRx((api) => { + return api.query.slp.delegationsOccupied({ Native: currency }); + }, TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint); +}; + +export default useDelegationsOccupiedStatus; diff --git a/apps/tangle-dapp/data/liquidStaking/useExchangeRate.ts b/apps/tangle-dapp/data/liquidStaking/useExchangeRate.ts new file mode 100644 index 000000000..053bd8c0f --- /dev/null +++ b/apps/tangle-dapp/data/liquidStaking/useExchangeRate.ts @@ -0,0 +1,58 @@ +import { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK } from '@webb-tools/webb-ui-components/constants/networks'; +import { useMemo } from 'react'; + +import { + LiquidStakingCurrency, + LiquidStakingCurrencyKey, +} from '../../constants/liquidStaking'; +import useApiRx from '../../hooks/useApiRx'; +import calculateBnRatio from '../../utils/calculateBnRatio'; + +export enum ExchangeRateType { + NativeToLiquid, + LiquidToNative, +} + +const useExchangeRate = ( + type: ExchangeRateType, + currency: LiquidStakingCurrency, +) => { + const { result: tokenPoolAmount } = useApiRx((api) => { + const key: LiquidStakingCurrencyKey = + type === ExchangeRateType.NativeToLiquid + ? { Native: currency } + : { lst: currency }; + + return api.query.lstMinting.tokenPool(key); + }, TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint); + + const { result: lstTotalIssuance } = useApiRx((api) => { + return api.query.tokens.totalIssuance({ lst: currency }); + }, TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint); + + const exchangeRate = useMemo(() => { + if (tokenPoolAmount === null || lstTotalIssuance === null) { + return null; + } + + const isEitherZero = tokenPoolAmount.isZero() || lstTotalIssuance.isZero(); + + // TODO: Need to review whether this is the right way to handle this edge case. + // Special case: No native tokens or liquidity available for conversion. + // Default to 1:1 exchange rate. This also helps prevent division by zero. + if (isEitherZero) { + return 1; + } + + const ratio = + type === ExchangeRateType.NativeToLiquid + ? calculateBnRatio(lstTotalIssuance, tokenPoolAmount) + : calculateBnRatio(tokenPoolAmount, lstTotalIssuance); + + return ratio; + }, [lstTotalIssuance, tokenPoolAmount, type]); + + return exchangeRate; +}; + +export default useExchangeRate; diff --git a/apps/tangle-dapp/data/liquidStaking/useMintAndRedeemFees.ts b/apps/tangle-dapp/data/liquidStaking/useMintAndRedeemFees.ts new file mode 100644 index 000000000..7a65dc678 --- /dev/null +++ b/apps/tangle-dapp/data/liquidStaking/useMintAndRedeemFees.ts @@ -0,0 +1,27 @@ +import { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK } from '@webb-tools/webb-ui-components/constants/networks'; +import { useCallback } from 'react'; +import { map } from 'rxjs'; + +import useApiRx from '../../hooks/useApiRx'; +import permillToPercentage from '../../utils/permillToPercentage'; + +const useMintAndRedeemFees = () => { + return useApiRx( + useCallback((api) => { + return api.query.lstMinting.fees().pipe( + map((fees) => { + const mintFee = permillToPercentage(fees[0]); + const redeemFee = permillToPercentage(fees[1]); + + return { + mintFee, + redeemFee, + }; + }), + ); + }, []), + TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint, + ); +}; + +export default useMintAndRedeemFees; diff --git a/apps/tangle-dapp/data/liquidStaking/useMintTx.ts b/apps/tangle-dapp/data/liquidStaking/useMintTx.ts index 29ad3e81c..02c5ae8c1 100644 --- a/apps/tangle-dapp/data/liquidStaking/useMintTx.ts +++ b/apps/tangle-dapp/data/liquidStaking/useMintTx.ts @@ -3,16 +3,19 @@ import '@webb-tools/tangle-restaking-types'; import { Bytes } from '@polkadot/types'; -import { TanglePrimitivesCurrencyTokenSymbol } from '@polkadot/types/lookup'; import { BN } from '@polkadot/util'; import { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK } from '@webb-tools/webb-ui-components/constants/networks'; import { TxName } from '../../constants'; +import { + LiquidStakingCurrency, + LiquidStakingCurrencyKey, +} from '../../constants/liquidStaking'; import { useSubstrateTxWithNotification } from '../../hooks/useSubstrateTx'; export type MintTxContext = { amount: BN; - currency: TanglePrimitivesCurrencyTokenSymbol['type']; + currency: LiquidStakingCurrency; }; const useMintTx = () => { @@ -20,14 +23,12 @@ const useMintTx = () => { return useSubstrateTxWithNotification( TxName.MINT, - (api, _activeSubstrateAddress, context) => + (api, _activeSubstrateAddress, context) => { + const key: LiquidStakingCurrencyKey = { Native: context.currency }; + // TODO: Investigate what the `remark` and `channel` parameters are for, and whether they are relevant for us here. - api.tx.lstMinting.mint( - { Native: context.currency }, - context.amount, - Bytes.from([]), - null, - ), + return api.tx.lstMinting.mint(key, context.amount, Bytes.from([]), null); + }, undefined, TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint, ); diff --git a/apps/tangle-dapp/data/liquidStaking/useOngoingTimeUnits.ts b/apps/tangle-dapp/data/liquidStaking/useOngoingTimeUnits.ts new file mode 100644 index 000000000..fb703b62c --- /dev/null +++ b/apps/tangle-dapp/data/liquidStaking/useOngoingTimeUnits.ts @@ -0,0 +1,32 @@ +import { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK } from '@webb-tools/webb-ui-components/constants/networks'; +import { useCallback, useMemo } from 'react'; + +import useApiRx from '../../hooks/useApiRx'; +import getValueOfTangleCurrency from './getValueOfTangleCurrency'; + +const useOngoingTimeUnits = () => { + const { result: ongoingTimeUnits } = useApiRx( + useCallback((api) => { + return api.query.lstMinting.ongoingTimeUnit.entries(); + }, []), + TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint, + ); + + const ongoingTimeUnitsMap = useMemo(() => { + if (ongoingTimeUnits === null) { + return null; + } + + // TODO: This is incomplete, because the key must also account for the currency type (ie. Native, LST, etc.). + return new Map( + ongoingTimeUnits.map(([key, value]) => [ + getValueOfTangleCurrency(key.args[0]), + value, + ]), + ); + }, [ongoingTimeUnits]); + + return ongoingTimeUnitsMap; +}; + +export default useOngoingTimeUnits; diff --git a/apps/tangle-dapp/data/liquidStaking/useParachainBalances.ts b/apps/tangle-dapp/data/liquidStaking/useParachainBalances.ts index a6c45fd3c..bffd30701 100644 --- a/apps/tangle-dapp/data/liquidStaking/useParachainBalances.ts +++ b/apps/tangle-dapp/data/liquidStaking/useParachainBalances.ts @@ -23,7 +23,6 @@ const useParachainBalances = () => { return null; } - // TODO: For some reason, the `api.query.tokens.accounts` method does not recognize passing in `null` for the token parameter, which is equivalent to passing `None` and should return the balance for all tokens. For now, manually casting the return type. return api.query.tokens.accounts.entries(activeSubstrateAddress); }, [activeSubstrateAddress], @@ -46,10 +45,14 @@ const useParachainBalances = () => { string | undefined >; - const entryValue = entry.lst ?? entry.Native; + // TODO: 'Native' balance isn't showing up on the parachain for some reason, not even under `api.query.balances.account()`. Is this a bug? Currently unable to obtain user account's native balance (defaulting to 0). Is it due to some sort of bridging mechanism? + const entryTokenValue = entry.lst ?? entry.Native; // Irrelevant entry, skip. - if (entryValue === undefined || !isLiquidStakingToken(entryValue)) { + if ( + entryTokenValue === undefined || + !isLiquidStakingToken(entryTokenValue) + ) { continue; } @@ -57,9 +60,9 @@ const useParachainBalances = () => { const balance = encodedBalance[1].free.toBn(); if (isLiquid) { - liquidBalances.set(entryValue, balance); + liquidBalances.set(entryTokenValue, balance); } else { - nativeBalances.set(entryValue, balance); + nativeBalances.set(entryTokenValue, balance); } } diff --git a/apps/tangle-dapp/data/liquidStaking/useRedeemTx.ts b/apps/tangle-dapp/data/liquidStaking/useRedeemTx.ts index 3534c26f3..f1066a7c8 100644 --- a/apps/tangle-dapp/data/liquidStaking/useRedeemTx.ts +++ b/apps/tangle-dapp/data/liquidStaking/useRedeemTx.ts @@ -2,25 +2,32 @@ // the `lstMinting` pallet, allowing us to use the `redeem` extrinsic. import '@webb-tools/tangle-restaking-types'; -import { TanglePrimitivesCurrencyTokenSymbol } from '@polkadot/types/lookup'; import { BN } from '@polkadot/util'; import { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK } from '@webb-tools/webb-ui-components/constants/networks'; import { TxName } from '../../constants'; +import { + LiquidStakingCurrency, + LiquidStakingCurrencyKey, +} from '../../constants/liquidStaking'; import { useSubstrateTxWithNotification } from '../../hooks/useSubstrateTx'; export type RedeemTxContext = { amount: BN; - currency: TanglePrimitivesCurrencyTokenSymbol['type']; + currency: LiquidStakingCurrency; }; const useRedeemTx = () => { // TODO: Add support for EVM accounts once precompile(s) for the `lstMinting` pallet are implemented on Tangle. + // TODO: Consider moving checks, such as checking that the provided amount equals or greater than 'minimumMint' amount here instead of in the consumer of this hook. return useSubstrateTxWithNotification( TxName.REDEEM, - (api, _activeSubstrateAddress, context) => - api.tx.lstMinting.redeem({ lst: context.currency }, context.amount), + (api, _activeSubstrateAddress, context) => { + const key: LiquidStakingCurrencyKey = { lst: context.currency }; + + return api.tx.lstMinting.redeem(key, context.amount); + }, undefined, TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint, ); diff --git a/apps/tangle-dapp/data/liquidStaking/useTokenUnlockDurations.ts b/apps/tangle-dapp/data/liquidStaking/useTokenUnlockDurations.ts new file mode 100644 index 000000000..cf4912b9c --- /dev/null +++ b/apps/tangle-dapp/data/liquidStaking/useTokenUnlockDurations.ts @@ -0,0 +1,29 @@ +// This will override global types and provide type definitions for +// the `lstMinting` pallet. +import '@webb-tools/tangle-restaking-types'; + +import { TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK } from '@webb-tools/webb-ui-components/constants/networks'; +import { useCallback, useMemo } from 'react'; + +import useApiRx from '../../hooks/useApiRx'; + +const useTokenUnlockDurations = () => { + const { result: entries } = useApiRx( + useCallback((api) => { + return api.query.lstMinting.unlockDuration.entries(); + }, []), + TANGLE_RESTAKING_PARACHAIN_LOCAL_DEV_NETWORK.wsRpcEndpoint, + ); + + const entriesMap = useMemo(() => { + if (entries === null) { + return null; + } + + return new Map(entries.map(([key, value]) => [key.args[0], value])); + }, [entries]); + + return entriesMap; +}; + +export default useTokenUnlockDurations; diff --git a/apps/tangle-dapp/hooks/useInputAmount.ts b/apps/tangle-dapp/hooks/useInputAmount.ts index b516d847c..5506ce226 100644 --- a/apps/tangle-dapp/hooks/useInputAmount.ts +++ b/apps/tangle-dapp/hooks/useInputAmount.ts @@ -39,7 +39,7 @@ function safeParseInputAmount( const INPUT_AMOUNT_FORMAT: Partial = { includeCommas: true, - fractionLength: undefined, + fractionMaxLength: undefined, }; type Options = { @@ -47,10 +47,10 @@ type Options = { min?: BN | null; max?: BN | null; errorOnEmptyValue?: boolean; - setAmount?: (newAmount: BN | null) => void; minErrorMessage?: string; maxErrorMessage?: string; decimals: number; + setAmount?: (newAmount: BN | null) => void; }; const useInputAmount = ({ @@ -58,12 +58,13 @@ const useInputAmount = ({ min = null, max = null, errorOnEmptyValue = false, - setAmount, minErrorMessage, maxErrorMessage, decimals, + setAmount, }: Options) => { const [errorMessage, setErrorMessage] = useState(null); + const [displayAmount, setDisplayAmount] = useState( amount !== null ? formatBn(amount, decimals, INPUT_AMOUNT_FORMAT) : '', ); @@ -86,10 +87,15 @@ const useInputAmount = ({ }) .join(''); + // Nothing to do. + if (displayAmount === cleanAmountString) { + return; + } + setDisplayAmount(cleanAmountString); const amountOrError = safeParseInputAmount({ - amountString: newAmountString, + amountString: cleanAmountString, min, max, errorOnEmptyValue, @@ -104,13 +110,14 @@ const useInputAmount = ({ } // If there was no error on the validation of the new amount string, // convert it to chain units and set it as the new amount. - else if (setAmount !== undefined && !newAmountString.endsWith('.')) { + else if (setAmount !== undefined && !cleanAmountString.endsWith('.')) { setErrorMessage(null); setAmount(amountOrError); } }, [ decimals, + displayAmount, errorOnEmptyValue, max, maxErrorMessage, @@ -136,10 +143,33 @@ const useInputAmount = ({ } }, [amount]); + const trySetAmount = useCallback( + (newAmount: BN): boolean => { + // Only accept the new amount if it is within the min and max bounds. + if (max !== null && newAmount.gt(max)) { + return false; + } else if (min !== null && newAmount.lt(min)) { + return false; + } + // No closure was provided to set the new amount. + else if (setAmount === undefined) { + return false; + } + + setAmount(newAmount); + + // TODO: Update the display amount to reflect the new amount. Must format the BN to a string. + + return true; + }, + [max, min, setAmount], + ); + return { displayAmount, errorMessage, handleChange, + trySetAmount, updateDisplayAmountManual, }; }; diff --git a/apps/tangle-dapp/hooks/useLSTokenSVGs.ts b/apps/tangle-dapp/hooks/useLSTokenSVGs.ts index 1cd261b94..e3a011ae4 100644 --- a/apps/tangle-dapp/hooks/useLSTokenSVGs.ts +++ b/apps/tangle-dapp/hooks/useLSTokenSVGs.ts @@ -16,8 +16,7 @@ const tokenSVGs: { GLMR, MANTA, PHALA, - // TODO: Awaiting internal renaming of BNC -> TNT in the Tangle Restaking Parachain. - BNC: TNT, + TNT, }; const useLSTokenSVGs = ( diff --git a/apps/tangle-dapp/hooks/useSubstrateTx.ts b/apps/tangle-dapp/hooks/useSubstrateTx.ts index 7de2b0a71..3885a6e4a 100644 --- a/apps/tangle-dapp/hooks/useSubstrateTx.ts +++ b/apps/tangle-dapp/hooks/useSubstrateTx.ts @@ -237,6 +237,7 @@ export function useSubstrateTxWithNotification( const execute = useCallback( (context: Context) => { + // TODO: Consider whether to change this to an assertion, since at this point the execute function shouldn't be null otherwise this function should not have been called. if (execute_ === null) { return; } @@ -269,5 +270,13 @@ export function useSubstrateTxWithNotification( } }, [status, error, txHash, notifyError, notifySuccess, successMessage]); - return { execute, status, error, txHash, successMessage }; + return { + // Prevent the consumer from executing the transaction if + // the underlying hook is not ready to do so. + execute: execute_ === null ? null : execute, + status, + error, + txHash, + successMessage, + }; } diff --git a/apps/tangle-dapp/hooks/useTxNotification.tsx b/apps/tangle-dapp/hooks/useTxNotification.tsx index 9cc92a532..202773d1b 100644 --- a/apps/tangle-dapp/hooks/useTxNotification.tsx +++ b/apps/tangle-dapp/hooks/useTxNotification.tsx @@ -29,7 +29,7 @@ const SUCCESS_MESSAGES: Record = { [TxName.UPDATE_RESTAKE_PROFILE]: 'Restake profile updated', [TxName.BRIDGE_TRANSFER]: 'Bridge transferred successful', [TxName.MINT]: 'Minted tokens', - [TxName.REDEEM]: 'Redeemed tokens', + [TxName.REDEEM]: 'Redeem request submitted', }; // TODO: Use a ref for the key to permit multiple rapid fire transactions from stacking under the same key. Otherwise, use a global state counter via Zustand. diff --git a/apps/tangle-dapp/tailwind.config.js b/apps/tangle-dapp/tailwind.config.js index 163b3c689..2fb175806 100644 --- a/apps/tangle-dapp/tailwind.config.js +++ b/apps/tangle-dapp/tailwind.config.js @@ -20,6 +20,9 @@ module.exports = { ], theme: { extend: { + colors: { + primary: '#8E59FF', + }, backgroundImage: { glass: 'linear-gradient(180deg,rgba(255,255,255,0.80) 0%,rgba(255,255,255,0.00) 100%)', diff --git a/apps/tangle-dapp/types/utils.ts b/apps/tangle-dapp/types/utils.ts index 833a46efd..5ad4b1b95 100644 --- a/apps/tangle-dapp/types/utils.ts +++ b/apps/tangle-dapp/types/utils.ts @@ -117,3 +117,14 @@ export type TransformEnum = ? never : AsEnumValuesToPrimitive >; + +export type Brand = Type & { __brand: Name }; + +export type RemoveBrand = { __brand: never }; + +export type AnySubstrateAddress = Brand; + +export type SubstrateAddress = Brand< + string, + 'SubstrateAddress' & { ss58Format: SS58 } +>; diff --git a/apps/tangle-dapp/utils/addCommasToNumber.ts b/apps/tangle-dapp/utils/addCommasToNumber.ts new file mode 100644 index 000000000..708f87a1c --- /dev/null +++ b/apps/tangle-dapp/utils/addCommasToNumber.ts @@ -0,0 +1,42 @@ +import { BN } from '@polkadot/util'; + +/** + * Formats a BN value with commas every 3 digits. + * + * @example + * ```ts + * addCommasToInteger(new BN('123456789')); // '123,456,789' + * ``` + */ +const addCommasToNumber = (numberLike: BN | number | string): string => { + const valueAsString = numberLike.toString(); + + // Sanity check that the value is not already formatted. + if (typeof numberLike === 'string' && numberLike.includes(',')) { + console.warn('Attempted to add commas to a number that already has commas'); + + return numberLike; + } + // This will format the number as a string with commas every 3 digits. + else if (typeof numberLike === 'number') { + return numberLike.toLocaleString('en-US'); + } + + let result = ''; + let count = 0; + + // Iterate through the string representation of the value in reverse order. + for (let i = valueAsString.length - 1; i >= 0; i--) { + result = valueAsString[i] + result; + count++; + + // Add a comma every 3 digits, except for the first digit. + if (count % 3 === 0 && i !== 0) { + result = ',' + result; + } + } + + return result; +}; + +export default addCommasToNumber; diff --git a/apps/tangle-dapp/utils/assertAnySubstrateAddress.ts b/apps/tangle-dapp/utils/assertAnySubstrateAddress.ts new file mode 100644 index 000000000..61c50d61e --- /dev/null +++ b/apps/tangle-dapp/utils/assertAnySubstrateAddress.ts @@ -0,0 +1,12 @@ +import { isAddress } from '@polkadot/util-crypto'; +import assert from 'assert'; + +import { AnySubstrateAddress } from '../types/utils'; + +const assertAnySubstrateAddress = (address: string): AnySubstrateAddress => { + assert(isAddress(address), 'Address should be a valid Substrate address'); + + return address as AnySubstrateAddress; +}; + +export default assertAnySubstrateAddress; diff --git a/apps/tangle-dapp/utils/calculateBnPercentage.ts b/apps/tangle-dapp/utils/calculateBnPercentage.ts deleted file mode 100644 index 022528429..000000000 --- a/apps/tangle-dapp/utils/calculateBnPercentage.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { BN } from '@polkadot/util'; -import assert from 'assert'; - -/** - * Given an amount, calculate its percentage of a total amount. - * - * The resulting percentage will be a Number with 2 decimal places, - * ex. `0.67`, ranging from 0 to 1. - * - * This is useful for integrating BN numbers into visual representation, - * such as when working with Recharts to chart BN amount allocations, - * since Recharts does not natively support BNs as data inputs. - * - * Because of the possible loss in precision, this utility function is - * only suitable for use in the UI. - */ -function calculateBnPercentage(amount: BN, total: BN): number { - assert( - !total.isZero(), - 'Total should not be zero, otherwise division by zero would occur', - ); - - assert(amount.lte(total), 'Amount should be less than or equal to total'); - - const scaledAmount = amount.muln(100); - const percentageString = scaledAmount.div(total).toString(); - - // Converting the string to a number ensures that the conversion to - // number never fails, but it may result in a loss of precision for - // extremely large values. - const percentage = Number(percentageString) / 100; - - // Round the percentage to 2 decimal places. It's suitable to use - // 2 decimal places since the purpose of this function is to provide - // a visual representation of the percentage in the UI. - return Math.round(percentage * 100) / 100; -} - -export default calculateBnPercentage; diff --git a/apps/tangle-dapp/utils/calculateBnRatio.ts b/apps/tangle-dapp/utils/calculateBnRatio.ts new file mode 100644 index 000000000..85c37547d --- /dev/null +++ b/apps/tangle-dapp/utils/calculateBnRatio.ts @@ -0,0 +1,34 @@ +import { BN } from '@polkadot/util'; +import assert from 'assert'; + +/** + * Given an amount, calculate its percentage of a total amount. + * + * The resulting percentage will be a Number with the requested decimal + * places, ex. `0.67`, ranging from 0 to 1. + * + * Because of the possible loss in precision, this utility function is + * only suitable for use in the UI. + * + * @throws If the second argument is zero. + */ +function calculateBnRatio(a: BN, b: BN, decimalPrecision = 2): number { + assert( + !b.isZero(), + 'The second argument should not be zero, otherwise division by zero would occur', + ); + + const precisionFactor = 10 ** decimalPrecision; + const scaledAmount = a.muln(precisionFactor); + const percentageString = scaledAmount.div(b).toString(); + + // Converting the string to a number ensures that the conversion to + // number never fails, but it may result in a loss of precision for + // extremely large values. + const percentage = Number(percentageString) / precisionFactor; + + // Round the percentage to the requested decimal places. + return Math.round(percentage * precisionFactor) / precisionFactor; +} + +export default calculateBnRatio; diff --git a/apps/tangle-dapp/utils/calculateTimeRemaining.ts b/apps/tangle-dapp/utils/calculateTimeRemaining.ts index ddf1f0c5d..c7d69cb01 100644 --- a/apps/tangle-dapp/utils/calculateTimeRemaining.ts +++ b/apps/tangle-dapp/utils/calculateTimeRemaining.ts @@ -1,10 +1,7 @@ import { formatDistance } from 'date-fns'; import capitalize from 'lodash/capitalize'; -function calculateTimeRemaining( - futureDate: Date, - currentDate?: Date, -): string | null { +function calculateTimeRemaining(futureDate: Date, currentDate?: Date): string { return capitalize(formatDistance(futureDate, currentDate ?? new Date())); } diff --git a/apps/tangle-dapp/utils/formatBn.ts b/apps/tangle-dapp/utils/formatBn.ts index 01897527f..4e4c05845 100644 --- a/apps/tangle-dapp/utils/formatBn.ts +++ b/apps/tangle-dapp/utils/formatBn.ts @@ -1,5 +1,7 @@ import { BN } from '@polkadot/util'; +import addCommasToNumber from './addCommasToNumber'; + /** * When the user inputs an amount in the UI, say using an Input * component, the amount needs to be treated as if it were in chain @@ -9,23 +11,22 @@ import { BN } from '@polkadot/util'; * `1` token, and not the smallest unit possible. * * To have the amount be in proper form, it needs to be multiplied by - * this factor (input amount * 10^18). + * this factor (input amount * 10^decimals). */ - -const convertChainUnitFactor = (decimals: number) => { +const getChainUnitFactor = (decimals: number) => { return new BN(10).pow(new BN(decimals)); }; export type FormatOptions = { includeCommas: boolean; - fractionLength?: number; - padZerosInFraction: boolean; + fractionMaxLength?: number; + trimTrailingZeroes: boolean; }; const DEFAULT_FORMAT_OPTIONS: FormatOptions = { - fractionLength: 4, - includeCommas: true, - padZerosInFraction: false, + fractionMaxLength: 4, + includeCommas: false, + trimTrailingZeroes: true, }; function formatBn( @@ -34,50 +35,60 @@ function formatBn( options?: Partial, ): string { const finalOptions = { ...DEFAULT_FORMAT_OPTIONS, ...options }; - const divisor = convertChainUnitFactor(decimals); - const divided = amount.div(divisor); - const remainder = amount.mod(divisor); + const chainUnitFactorBn = getChainUnitFactor(decimals); + const integerPartBn = amount.div(chainUnitFactorBn); + const remainderBn = amount.mod(chainUnitFactorBn); - let integerPart = divided.toString(10); + let integerPart = integerPartBn.toString(10); + let fractionPart = remainderBn.toString(10).padStart(decimals, '0'); - // Convert remainder to a string and pad with zeros if necessary. - let remainderString = remainder.toString(10); + const amountStringLength = amount.toString().length; + const partsLength = integerPart.length + fractionPart.length; - // There is a case when the decimals part has leading 0s, so that the remaining - // string can missing those 0s when we use `mod` method. - // Solution: Try to construct the string again and check the length, - // if the length is not the same, we can say that the remainder string is missing - // leading 0s, so we try to prepend those 0s to the remainder string - if (amount.toString().length !== (integerPart + remainderString).length) { - const missing0sCount = - amount.toString().length - (integerPart + remainderString).length; + // Check for missing leading zeros in the fraction part. This + // edge case can happen when the remainder has fewer digits + // than the specified decimals, resulting in a loss of leading + // zeros when converting to a string, ex. 0001 -> 1. + if (amountStringLength !== partsLength) { + // Count how many leading zeros are missing. + const missingZerosCount = amountStringLength - partsLength; - remainderString = - Array.from({ length: missing0sCount }) - .map(() => '0') - .join('') + remainderString; + // Add the missing leading zeros. Use the max function to avoid + // strange situations where the count is negative (ie. the length + // of the number is greater than the length of the integer and fraction + // parts combined). + fractionPart = '0'.repeat(Math.max(missingZerosCount, 0)) + fractionPart; } - if (finalOptions.padZerosInFraction) { - remainderString = remainderString.padStart(decimals, '0'); + // Pad the end of the fraction part with zeros if applicable, + // ex. 0.001 -> 0.0010 when the requested fraction length is 4. + if (!finalOptions.trimTrailingZeroes) { + fractionPart = fractionPart.padEnd( + finalOptions.fractionMaxLength ?? decimals, + '0', + ); } - remainderString = remainderString.substring(0, finalOptions.fractionLength); + // Trim the fraction part to the desired length. + if (finalOptions.fractionMaxLength !== undefined) { + fractionPart = fractionPart.substring(0, finalOptions.fractionMaxLength); + } - // Remove trailing 0s. - while (remainderString.endsWith('0')) { - remainderString = remainderString.substring(0, remainderString.length - 1); + // Remove trailing zeroes if applicable. + if (finalOptions.trimTrailingZeroes) { + while (fractionPart.endsWith('0')) { + fractionPart = fractionPart.substring(0, fractionPart.length - 1); + } } // Insert commas in the integer part if requested. if (finalOptions.includeCommas) { - // TODO: Avoid using regex, it's confusing. - integerPart = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + integerPart = addCommasToNumber(integerPart); } - // TODO: Make the condition explicit. Is it checking for an empty string? - // Combine the integer and decimal parts. - return remainderString ? `${integerPart}.${remainderString}` : integerPart; + // Combine the integer and fraction parts. Only include the fraction + // part if it's available. + return fractionPart !== '' ? `${integerPart}.${fractionPart}` : integerPart; } export default formatBn; diff --git a/apps/tangle-dapp/utils/formatBnWithCommas.ts b/apps/tangle-dapp/utils/formatBnWithCommas.ts deleted file mode 100644 index feff08031..000000000 --- a/apps/tangle-dapp/utils/formatBnWithCommas.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BN } from '@polkadot/util'; - -/** - * Formats a BN value with commas every 3 digits. - * - * @example - * ```ts - * formatBnWithCommas(new BN('123456789')); // '123,456,789' - * ``` - */ -export const formatBnWithCommas = (bn: BN): string => { - // TODO: Incorporate this into the logic of `formatBn` to consolidate balance formatting logic. - - const valueAsString = bn.toString(); - let result = ''; - let count = 0; - - // Iterate through the string representation of the value in reverse order. - for (let i = valueAsString.length - 1; i >= 0; i--) { - result = valueAsString[i] + result; - count++; - - // Add a comma every 3 digits, except for the first digit. - if (count % 3 === 0 && i !== 0) { - result = ',' + result; - } - } - - return result; -}; diff --git a/apps/tangle-dapp/utils/formatTangleBalance.ts b/apps/tangle-dapp/utils/formatTangleBalance.ts index 175a96342..634d6474e 100644 --- a/apps/tangle-dapp/utils/formatTangleBalance.ts +++ b/apps/tangle-dapp/utils/formatTangleBalance.ts @@ -12,6 +12,7 @@ const formatTangleBalance = ( return formatBalance(balance, { decimals: TANGLE_TOKEN_DECIMALS, withZero: false, + // TODO: There is a bug here, since small balances will show up as 0 because of this option. // This ensures that the balance is always displayed in the // base unit, preventing the conversion to larger or smaller // units (e.g. kilo, milli, etc.). diff --git a/apps/tangle-dapp/utils/isAnySubstrateAddress.ts b/apps/tangle-dapp/utils/isAnySubstrateAddress.ts new file mode 100644 index 000000000..05c5fa94f --- /dev/null +++ b/apps/tangle-dapp/utils/isAnySubstrateAddress.ts @@ -0,0 +1,11 @@ +import { isAddress } from '@polkadot/util-crypto'; + +import { AnySubstrateAddress, RemoveBrand } from '../types/utils'; + +const isAnySubstrateAddress = ( + address: string, +): address is AnySubstrateAddress & RemoveBrand => { + return isAddress(address); +}; + +export default isAnySubstrateAddress; diff --git a/apps/tangle-dapp/utils/liquidStaking/addTimeUnits.ts b/apps/tangle-dapp/utils/liquidStaking/addTimeUnits.ts new file mode 100644 index 000000000..f4be9ec81 --- /dev/null +++ b/apps/tangle-dapp/utils/liquidStaking/addTimeUnits.ts @@ -0,0 +1,47 @@ +import { TanglePrimitivesTimeUnit } from '@polkadot/types/lookup'; +import assert from 'assert'; + +import { LiquidStakingTimeUnitInstance } from '../../constants/liquidStaking'; + +const getValueOfTangleTimeUnit = ( + tangleTimeUnit: TanglePrimitivesTimeUnit, +): number => { + // Unfortunately, there doesn't seem to be a cleaner way of + // going about this. This is a direct cause of the way that + // Rust enums are generated to be used in TypeScript/JavaScript. + if (tangleTimeUnit.isEra) { + return tangleTimeUnit.asEra.toNumber(); + } else if (tangleTimeUnit.isHour) { + return tangleTimeUnit.asHour.toNumber(); + } else if (tangleTimeUnit.isKblock) { + return tangleTimeUnit.asKblock.toNumber(); + } else if (tangleTimeUnit.isSlashingSpan) { + return tangleTimeUnit.asSlashingSpan.toNumber(); + } else if (tangleTimeUnit.isRound) { + return tangleTimeUnit.asRound.toNumber(); + } + + throw new Error( + `Unknown or unsupported time unit type: ${tangleTimeUnit.type} (was the Tangle Restaking Parachain updated?)`, + ); +}; + +const addTimeUnits = ( + a: TanglePrimitivesTimeUnit, + b: TanglePrimitivesTimeUnit, +): LiquidStakingTimeUnitInstance => { + assert( + a.type === b.type, + `Time units must be of the same type, otherwise the addition is not possible; Received: ${a.type} and ${b.type}`, + ); + + const valueOfA = getValueOfTangleTimeUnit(a); + const valueOfB = getValueOfTangleTimeUnit(b); + + return { + unit: a.type, + value: valueOfA + valueOfB, + }; +}; + +export default addTimeUnits; diff --git a/apps/tangle-dapp/utils/permillToPercentage.ts b/apps/tangle-dapp/utils/permillToPercentage.ts new file mode 100644 index 000000000..6f448b6fd --- /dev/null +++ b/apps/tangle-dapp/utils/permillToPercentage.ts @@ -0,0 +1,7 @@ +import { Permill } from '@polkadot/types/interfaces'; + +const permillToPercentage = (permill: Permill) => { + return permill.toNumber() / 1_000_000; +}; + +export default permillToPercentage; diff --git a/apps/tangle-dapp/utils/scaleAmountByPermill.ts b/apps/tangle-dapp/utils/scaleAmountByPermill.ts new file mode 100644 index 000000000..8c215f041 --- /dev/null +++ b/apps/tangle-dapp/utils/scaleAmountByPermill.ts @@ -0,0 +1,14 @@ +import { BN } from '@polkadot/util'; + +const scaleAmountByPermill = (amount: BN, permill: number): BN => { + // Scale factor for 4 decimal places (0.xxxx). + const scale = new BN(10_000); + + // Scale the permill to an integer. + const scaledPermill = new BN(Math.round(permill * scale.toNumber())); + + // Multiply the amount by the scaled permill and then divide by the scale. + return amount.mul(scaledPermill).div(scale); +}; + +export default scaleAmountByPermill; diff --git a/libs/icons/src/TimeFillIcon.tsx b/libs/icons/src/TimeFillIcon.tsx new file mode 100644 index 000000000..79c4bfc18 --- /dev/null +++ b/libs/icons/src/TimeFillIcon.tsx @@ -0,0 +1,11 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +export const TimeFillIcon = (props: IconBase) => { + return createIcon({ + ...props, + viewBox: '0 0 14 14', + d: 'M6.75004 13.6668C3.06814 13.6668 0.083374 10.682 0.083374 7.00016C0.083374 3.31826 3.06814 0.333496 6.75004 0.333496C10.4319 0.333496 13.4167 3.31826 13.4167 7.00016C13.4167 10.682 10.4319 13.6668 6.75004 13.6668ZM7.41671 7.00016V3.66683H6.08337V8.3335H10.0834V7.00016H7.41671Z', + displayName: 'TimeFillIcon', + }); +}; diff --git a/libs/icons/src/UndoIcon.tsx b/libs/icons/src/UndoIcon.tsx new file mode 100644 index 000000000..d079c413a --- /dev/null +++ b/libs/icons/src/UndoIcon.tsx @@ -0,0 +1,11 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +export const UndoIcon = (props: IconBase) => { + return createIcon({ + ...props, + viewBox: '0 0 13 14', + d: 'M4.33337 4.1665V6.83317L0.333374 3.49984L4.33337 0.166504V2.83317H7.66671C10.6122 2.83317 13 5.22098 13 8.1665C13 11.112 10.6122 13.4998 7.66671 13.4998H1.66671V12.1665H7.66671C9.87584 12.1665 11.6667 10.3756 11.6667 8.1665C11.6667 5.95736 9.87584 4.1665 7.66671 4.1665H4.33337Z', + displayName: 'UndoIcon', + }); +}; diff --git a/libs/icons/src/index.ts b/libs/icons/src/index.ts index e52080e75..9e1724ff4 100644 --- a/libs/icons/src/index.ts +++ b/libs/icons/src/index.ts @@ -145,6 +145,8 @@ export { default as WalletPayIcon } from './WalletPayIcon'; export { default as WebbLogoIcon } from './WebbLogoIcon'; export * from './YouTubeFill'; export * from './WaterDropletIcon'; +export * from './UndoIcon'; +export * from './TimeFillIcon'; // Wallet icons export * from './wallets'; diff --git a/libs/webb-ui-components/src/components/buttons/IconButton.tsx b/libs/webb-ui-components/src/components/buttons/IconButton.tsx index 61da231c0..75dfa636e 100644 --- a/libs/webb-ui-components/src/components/buttons/IconButton.tsx +++ b/libs/webb-ui-components/src/components/buttons/IconButton.tsx @@ -1,10 +1,11 @@ import React, { forwardRef } from 'react'; import { twMerge } from 'tailwind-merge'; import { IconButtonProps } from './types'; +import { Tooltip, TooltipBody, TooltipTrigger } from '../Tooltip'; const IconButton = forwardRef, IconButtonProps>( - ({ className, ...props }, ref) => { - return ( + ({ className, tooltip, ...props }, ref) => { + const content = (