From 1ac0c7f029a1d5c41af2303d524665b5750c4671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sim=C3=A3o?= Date: Mon, 6 Nov 2023 11:58:30 +0000 Subject: [PATCH] feat(Staking): refactor (#1538) * feat(Staking): refactor * feat: continue * wip * feat: continue * feat: continue * feat: final * feat: final * feat: add tests * feat: continue tests * feat: continue * feat: add translations * fix: code review * feat: add staking limit * fix: limit * fix(Staking): limit parsing --------- Co-authored-by: tomjeatt <40243778+tomjeatt@users.noreply.github.com> --- .github/workflows/test.yml | 1 + src/assets/locales/en/translation.json | 31 +- src/component-library/Field/Field.style.tsx | 12 +- src/component-library/Field/Field.tsx | 58 +- src/component-library/Input/BaseInput.tsx | 11 +- src/component-library/Input/Input.stories.tsx | 8 +- src/component-library/Label/Label.style.tsx | 12 +- src/component-library/Label/Label.tsx | 13 +- src/component-library/List/List.stories.tsx | 58 +- src/component-library/List/List.style.tsx | 1 + .../NumberInput/NumberInput.tsx | 32 +- .../TokenInput/TokenInput.tsx | 4 + src/component-library/utils/prop-types.ts | 2 + src/components/ClaimModal/ClaimModal.tsx | 85 ++ src/components/ClaimModal/index.tsx | 2 + .../SlippageManager/SlippageManager.tsx | 3 +- src/components/index.tsx | 2 + .../use-get-account-claimable-rewards.tsx | 36 + .../escrow/use-get-account-staking-data.tsx | 66 +- .../escrow/use-get-account-voting-balance.tsx | 37 - .../escrow/use-get-staking-details-data.tsx | 101 +++ .../escrow/uset-get-network-staking-data.tsx | 46 + src/hooks/transaction/extrinsics/lib.ts | 5 +- src/hooks/transaction/types/escrow.ts | 4 +- src/hooks/transaction/utils/fee.ts | 17 + src/lib/form/schemas/index.ts | 1 + src/lib/form/schemas/staking.ts | 56 ++ src/pages/Staking/BalancesUI/index.tsx | 107 --- .../Staking/ClaimRewardsButton/index.tsx | 48 - src/pages/Staking/InformationUI/index.tsx | 45 - src/pages/Staking/LockTimeField/index.tsx | 106 --- src/pages/Staking/Staking.style.tsx | 31 + src/pages/Staking/Staking.tsx | 49 + src/pages/Staking/TotalsUI/index.tsx | 99 -- src/pages/Staking/WithdrawButton/index.tsx | 83 -- .../StakingAccountDetails.tsx | 100 +++ .../StakingAccountDetails/index.tsx | 2 + .../StakingForm/StakingForm.style.tsx | 9 + .../components/StakingForm/StakingForm.tsx | 281 ++++++ .../StakingForm/StakingLockTimeInput.tsx | 109 +++ .../StakingForm/StakingNetworkDetails.tsx | 47 + .../StakingForm/StakingTransactionDetails.tsx | 73 ++ .../Staking/components/StakingForm/index.tsx | 2 + .../StakingWithdrawCard.tsx | 65 ++ .../components/StakingWithdrawCard/index.tsx | 2 + src/pages/Staking/components/index.tsx | 2 + src/pages/Staking/index.tsx | 848 +----------------- .../Wallet/WalletOverview/WalletOverview.tsx | 9 +- .../components/StakingTable/StakingTable.tsx | 13 +- .../mocks/@interlay/interbtc-api/index.ts | 7 +- .../@interlay/interbtc-api/parachain/api.ts | 13 +- .../interbtc-api/parachain/escrow.ts | 73 +- .../@interlay/interbtc-api/parachain/index.ts | 1 + src/test/pages/Staking.test.tsx | 266 ++++++ src/test/pages/Wallet.test.tsx | 13 +- src/utils/helpers/staking.ts | 13 + 56 files changed, 1725 insertions(+), 1495 deletions(-) create mode 100644 src/components/ClaimModal/ClaimModal.tsx create mode 100644 src/components/ClaimModal/index.tsx create mode 100644 src/hooks/api/escrow/use-get-account-claimable-rewards.tsx delete mode 100644 src/hooks/api/escrow/use-get-account-voting-balance.tsx create mode 100644 src/hooks/api/escrow/use-get-staking-details-data.tsx create mode 100644 src/hooks/api/escrow/uset-get-network-staking-data.tsx create mode 100644 src/lib/form/schemas/staking.ts delete mode 100644 src/pages/Staking/BalancesUI/index.tsx delete mode 100644 src/pages/Staking/ClaimRewardsButton/index.tsx delete mode 100644 src/pages/Staking/InformationUI/index.tsx delete mode 100644 src/pages/Staking/LockTimeField/index.tsx create mode 100644 src/pages/Staking/Staking.style.tsx create mode 100644 src/pages/Staking/Staking.tsx delete mode 100644 src/pages/Staking/TotalsUI/index.tsx delete mode 100644 src/pages/Staking/WithdrawButton/index.tsx create mode 100644 src/pages/Staking/components/StakingAccountDetails/StakingAccountDetails.tsx create mode 100644 src/pages/Staking/components/StakingAccountDetails/index.tsx create mode 100644 src/pages/Staking/components/StakingForm/StakingForm.style.tsx create mode 100644 src/pages/Staking/components/StakingForm/StakingForm.tsx create mode 100644 src/pages/Staking/components/StakingForm/StakingLockTimeInput.tsx create mode 100644 src/pages/Staking/components/StakingForm/StakingNetworkDetails.tsx create mode 100644 src/pages/Staking/components/StakingForm/StakingTransactionDetails.tsx create mode 100644 src/pages/Staking/components/StakingForm/index.tsx create mode 100644 src/pages/Staking/components/StakingWithdrawCard/StakingWithdrawCard.tsx create mode 100644 src/pages/Staking/components/StakingWithdrawCard/index.tsx create mode 100644 src/pages/Staking/components/index.tsx create mode 100644 src/test/pages/Staking.test.tsx create mode 100644 src/utils/helpers/staking.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03d6e5f643..beb8c3734c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,7 @@ env: REACT_APP_FEATURE_FLAG_LENDING: enabled REACT_APP_FEATURE_FLAG_AMM: enabled REACT_APP_FEATURE_FLAG_WALLET: enabled + REACT_APP_BITCOIN_NETWORK: regtest jobs: lint: diff --git a/src/assets/locales/en/translation.json b/src/assets/locales/en/translation.json index d20252b086..96f09cb03f 100644 --- a/src/assets/locales/en/translation.json +++ b/src/assets/locales/en/translation.json @@ -144,6 +144,7 @@ "amount": "Amount", "select_token": "Select Token", "fee_token": "Fee token", + "claim": "Claim", "claim_rewards": "Claim Rewards", "tx_fees": "Tx fees", "view_subscan": "View Subscan", @@ -154,6 +155,10 @@ "rewards_apr_ticker": "Rewards APR {{ticker}}", "wallet": "Wallet", "learn_more": "Learn more", + "stake": "Stake", + "max": "Max", + "ticker_balance": "{{ticker}} Balance", + "claimable_rewards": "Claimable Rewards", "navigation": { "btc": "BTC", "strategies": "Strategies", @@ -535,11 +540,27 @@ } }, "staking_page": { - "the_estimated_amount_of_governance_token_you_will_receive_as_rewards": "The estimated amount of {{governanceTokenSymbol}} you will receive as rewards. Depends on your proportion of the total {{voteGovernanceTokenSymbol}}.", - "new_vote_governance_token_gained": "New {{voteGovernanceTokenSymbol}} Gained", - "the_increase_in_your_vote_governance_token_balance": "The increase in your {{voteGovernanceTokenSymbol}} balance", - "total_vote_governance_token_in_the_network": "Total {{voteGovernanceTokenSymbol}} in the network", - "total_staked_governance_token_in_the_network": "Total Staked {{governanceTokenSymbol}} in the network" + "stake_ticker": "Stake {{ticker}}", + "total_staked_ticker_in_the_network": "Total Staked {{ticker}} in the network", + "total_ticker_in_the_network": "Total {{ticker}} in the network", + "time": { + "one_week": "1 Week", + "one_month": "1 Month", + "three_month": "3 Month", + "six_month": "6 Month" + }, + "lock_time_in_weeks": "Lock time in weeks (Max {{value}})", + "extended_lock_time_in_weeks": "Extended lock time in weeks (Max {{value}})", + "your_already_staked_ticker_needs_to_be_withdrawn": "Your already staked {GOVERNANCE_TOKEN.ticker} needs to be withdrawn before adding a new stake", + "unlock_date": "Unlock date", + "new_unlock_date": "New unlock date", + "new_ticker_gained": "New {{ticker}} Gained", + "new_total_stake": "New Total Stake", + "estimated_apr": "Estimated APR", + "projected_ticker_rewards": "Projected {{ticker}} Rewards", + "staked_ticker": "Staked {{ticker}}", + "withdraw_staked_ticker_on_date": "Withdraw Staked {{ticker}} on", + "withdraw_staked_ticker": "Withdraw Staked {{ticker}}" }, "about": { "research_paper": "Research Paper", diff --git a/src/component-library/Field/Field.style.tsx b/src/component-library/Field/Field.style.tsx index 3533b0c3b4..f0570238bb 100644 --- a/src/component-library/Field/Field.style.tsx +++ b/src/component-library/Field/Field.style.tsx @@ -1,12 +1,18 @@ import styled from 'styled-components'; -import { Flex } from '../Flex'; import { theme } from '../theme'; +import { Spacing } from '../utils/prop-types'; -const Wrapper = styled(Flex)` +type StyledFieldProps = { + $maxWidth?: Spacing; +}; + +const StyledField = styled.div` position: relative; color: ${theme.colors.textPrimary}; box-sizing: border-box; + display: inline-flex; + max-width: ${({ $maxWidth }) => $maxWidth && theme.spacing[$maxWidth]}; `; -export { Wrapper }; +export { StyledField }; diff --git a/src/component-library/Field/Field.tsx b/src/component-library/Field/Field.tsx index ac9b7143c6..f9e9ee92e7 100644 --- a/src/component-library/Field/Field.tsx +++ b/src/component-library/Field/Field.tsx @@ -1,34 +1,47 @@ import { forwardRef, HTMLAttributes, ReactNode } from 'react'; -import { Flex } from '../Flex'; +import { Flex, FlexProps } from '../Flex'; import { HelperText, HelperTextProps } from '../HelperText'; import { Label, LabelProps } from '../Label'; import { hasError } from '../utils/input'; -import { Wrapper } from './Field.style'; +import { LabelPosition, Spacing } from '../utils/prop-types'; +import { StyledField } from './Field.style'; type Props = { label?: ReactNode; + labelPosition?: LabelPosition; labelProps?: LabelProps; + maxWidth?: Spacing; }; type NativeAttrs = Omit, keyof Props>; -type InheritAttrs = Omit; +type InheritAttrs = Omit; type FieldProps = Props & NativeAttrs & InheritAttrs; const Field = forwardRef( ( - { label, labelProps, errorMessage, errorMessageProps, description, descriptionProps, children, ...props }, + { + label, + labelPosition = 'top', + labelProps, + errorMessage, + errorMessageProps, + description, + descriptionProps, + children, + maxWidth, + ...props + }, ref ): JSX.Element => { const error = hasError({ errorMessage }); const hasHelpText = !!description || error; - return ( - - {label && } - {children} + const element = ( + <> + {children} {hasHelpText && ( ( errorMessageProps={errorMessageProps} /> )} + + ); + + return ( + + {label && ( + + )} + {labelPosition === 'top' ? ( + element + ) : ( + + {element} + + )} ); } @@ -46,6 +76,7 @@ Field.displayName = 'Field'; const useFieldProps = ({ label, + labelPosition, labelProps, errorMessage, errorMessageProps, @@ -54,11 +85,16 @@ const useFieldProps = ({ className, hidden, style, + maxWidth, + alignItems, + justifyContent, + gap, ...props }: FieldProps): { fieldProps: FieldProps; elementProps: any } => { return { fieldProps: { label, + labelPosition, labelProps, errorMessage, errorMessageProps, @@ -66,7 +102,11 @@ const useFieldProps = ({ descriptionProps, className, hidden, - style + style, + maxWidth, + alignItems, + justifyContent, + gap }, elementProps: props }; diff --git a/src/component-library/Input/BaseInput.tsx b/src/component-library/Input/BaseInput.tsx index 3f02528dd6..5e4adedc8b 100644 --- a/src/component-library/Input/BaseInput.tsx +++ b/src/component-library/Input/BaseInput.tsx @@ -1,16 +1,13 @@ import { ValidationState } from '@react-types/shared'; import { forwardRef, InputHTMLAttributes, ReactNode, useEffect, useRef, useState } from 'react'; -import { Field, useFieldProps } from '../Field'; +import { Field, FieldProps, useFieldProps } from '../Field'; import { HelperTextProps } from '../HelperText'; -import { LabelProps } from '../Label'; import { hasError } from '../utils/input'; import { Sizes, Spacing } from '../utils/prop-types'; import { Adornment, StyledBaseInput } from './Input.style'; type Props = { - label?: ReactNode; - labelProps?: LabelProps; startAdornment?: ReactNode; endAdornment?: ReactNode; bottomAdornment?: ReactNode; @@ -25,7 +22,11 @@ type Props = { type NativeAttrs = Omit, keyof Props>; -type InheritAttrs = Omit; +type InheritAttrs = Omit< + HelperTextProps & + Pick, + keyof Props & NativeAttrs +>; type BaseInputProps = Props & NativeAttrs & InheritAttrs; diff --git a/src/component-library/Input/Input.stories.tsx b/src/component-library/Input/Input.stories.tsx index fa0c7f0bf0..8446122c8b 100644 --- a/src/component-library/Input/Input.stories.tsx +++ b/src/component-library/Input/Input.stories.tsx @@ -6,9 +6,15 @@ const Template: Story = (args) => ; const Default = Template.bind({}); Default.args = { + placeholder: 'placeholder', + label: 'Coin' +}; + +const Side = Template.bind({}); +Side.args = { placeholder: 'placeholder', label: 'Coin', - description: "What's your favorite coin?" + labelPosition: 'side' }; const Label = Template.bind({}); diff --git a/src/component-library/Label/Label.style.tsx b/src/component-library/Label/Label.style.tsx index 242de77506..c28c4c4f9d 100644 --- a/src/component-library/Label/Label.style.tsx +++ b/src/component-library/Label/Label.style.tsx @@ -1,13 +1,21 @@ import styled from 'styled-components'; import { theme } from '../theme'; +import { LabelPosition } from '../utils/prop-types'; -const StyledLabel = styled.label` +type StyledLabelProps = { + $position: LabelPosition; +}; + +const StyledLabel = styled.label` font-weight: ${theme.fontWeight.medium}; line-height: ${theme.lineHeight.lg}; font-size: ${theme.text.xs}; color: ${theme.label.text}; - padding: ${theme.spacing.spacing1} 0; + padding: ${({ $position }) => + $position === 'top' + ? `${theme.spacing.spacing1} 0` + : `${theme.spacing.spacing2} ${theme.spacing.spacing1} 0.438rem 0`}; align-self: flex-start; `; diff --git a/src/component-library/Label/Label.tsx b/src/component-library/Label/Label.tsx index 927b2b149b..86416439f7 100644 --- a/src/component-library/Label/Label.tsx +++ b/src/component-library/Label/Label.tsx @@ -1,14 +1,19 @@ import { forwardRef, LabelHTMLAttributes } from 'react'; +import { LabelPosition } from '../utils/prop-types'; import { StyledLabel } from './Label.style'; -type NativeAttrs = LabelHTMLAttributes; +type Props = { + position?: LabelPosition; +}; -type LabelProps = NativeAttrs; +type NativeAttrs = Omit, keyof Props>; + +type LabelProps = Props & NativeAttrs; const Label = forwardRef( - ({ children, ...props }, ref): JSX.Element => ( - + ({ children, position = 'top', ...props }, ref): JSX.Element => ( + {children} ) diff --git a/src/component-library/List/List.stories.tsx b/src/component-library/List/List.stories.tsx index 8ad79ac4a3..ce03075e1f 100644 --- a/src/component-library/List/List.stories.tsx +++ b/src/component-library/List/List.stories.tsx @@ -1,28 +1,44 @@ import { Meta, Story } from '@storybook/react'; +import { useState } from 'react'; import { List, ListItem, ListProps } from '.'; -const Template: Story = (args) => ( -
- - - IBTC - - - KINT - - - INTR - - - KSM - - - DOT - - -
-); +const Template: Story = (args) => { + const [value, setValue] = useState(); + + const handleSelectionChange: ListProps['onSelectionChange'] = (key) => { + const [selectedKey] = [...key]; + + setValue(selectedKey?.toString()); + }; + + return ( +
+ + + IBTC + + + KINT + + + INTR + + + KSM + + + DOT + + +
+ ); +}; const Default = Template.bind({}); Default.args = { diff --git a/src/component-library/List/List.style.tsx b/src/component-library/List/List.style.tsx index d252659b61..6040950c52 100644 --- a/src/component-library/List/List.style.tsx +++ b/src/component-library/List/List.style.tsx @@ -35,6 +35,7 @@ const StyledListItem = styled.li` cursor: ${({ $isInteractable }) => $isInteractable && 'pointer'}; outline: ${({ $isFocusVisible }) => !$isFocusVisible && 'none'}; opacity: ${({ $isDisabled }) => $isDisabled && 0.5}; + white-space: nowrap; ${({ $variant }) => { if ($variant === 'card') { diff --git a/src/component-library/NumberInput/NumberInput.tsx b/src/component-library/NumberInput/NumberInput.tsx index a449c558cb..73ffe0a6b5 100644 --- a/src/component-library/NumberInput/NumberInput.tsx +++ b/src/component-library/NumberInput/NumberInput.tsx @@ -11,8 +11,10 @@ const escapeRegExp = (string: string): string => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; +const numericRegex = /^[0-9\b]+$/; + // match escaped "." characters via in a non-capturing group -const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`); +const decimalRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`); type Props = { value?: string | number; @@ -29,14 +31,31 @@ type AriaAttrs = Omit, (keyof Props & InheritAttrs type NumberInputProps = Props & InheritAttrs & AriaAttrs; const NumberInput = forwardRef( - ({ onChange, validationState, value: valueProp, defaultValue = '', ...props }, ref): JSX.Element => { + ( + { onChange, validationState, value: valueProp, defaultValue = '', inputMode = 'numeric', ...props }, + ref + ): JSX.Element => { const [value, setValue] = useState(defaultValue?.toString()); const inputRef = useDOMRef(ref); const handleChange: ChangeEventHandler = (e) => { const value = e.target.value; - if (inputRegex.test(escapeRegExp(value))) { + let isValid = true; + + switch (inputMode) { + case 'decimal': { + isValid = decimalRegex.test(escapeRegExp(value)); + + break; + } + case 'numeric': { + isValid = e.target.value === '' || numericRegex.test(e.target.value); + break; + } + } + + if (isValid) { onChange?.(e); setValue(value); } @@ -45,13 +64,10 @@ const NumberInput = forwardRef( const { inputProps, descriptionProps, errorMessageProps, labelProps } = useTextField( { ...props, + inputMode, validationState: props.errorMessage ? 'invalid' : validationState, value: value, - pattern: '^[0-9]*[.,]?[0-9]*$', - inputMode: 'decimal', - autoComplete: 'off', - minLength: 1, - maxLength: 79 + autoComplete: 'off' }, inputRef ); diff --git a/src/component-library/TokenInput/TokenInput.tsx b/src/component-library/TokenInput/TokenInput.tsx index 109f6148ad..8f3525a483 100644 --- a/src/component-library/TokenInput/TokenInput.tsx +++ b/src/component-library/TokenInput/TokenInput.tsx @@ -121,6 +121,10 @@ const TokenInput = forwardRef( )} void; + onOpen: () => void; + transaction: UseTransactionResult; + children?: ReactNode; + submitLabel: ReactNode; +}; + +type InheritAttrs = Omit; + +type ClaimModalProps = Props & InheritAttrs; + +const ClaimModal = ({ + isOpen, + title, + submitLabel, + children, + transaction, + onSubmit, + onOpen, + ...props +}: ClaimModalProps): JSX.Element => { + const overlappingModalRef = useRef(null); + + const form = useForm<{ [NAME]?: string }>({ + initialValues: { + [NAME]: '' + }, + validationSchema: yup.object().shape({ + [NAME]: yup.string().required() + }), + onSubmit, + onComplete: onOpen + }); + + // Doing this call on mount so that the form becomes dirty + useEffect(() => { + if (!isOpen) return; + + form.setFieldValue(NAME, transaction.fee.defaultCurrency.ticker, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const isBtnDisabled = isTransactionFormDisabled(form, transaction.fee); + + return ( + !overlappingModalRef.current?.contains(el)}> + {title} + {children && {children}} + +
+ + + + {submitLabel} + + +
+
+
+ ); +}; + +export { ClaimModal }; +export type { ClaimModalProps }; diff --git a/src/components/ClaimModal/index.tsx b/src/components/ClaimModal/index.tsx new file mode 100644 index 0000000000..f0ee787d35 --- /dev/null +++ b/src/components/ClaimModal/index.tsx @@ -0,0 +1,2 @@ +export type { ClaimModalProps } from './ClaimModal'; +export { ClaimModal } from './ClaimModal'; diff --git a/src/components/SlippageManager/SlippageManager.tsx b/src/components/SlippageManager/SlippageManager.tsx index aa13b8add5..6aa630b8ed 100644 --- a/src/components/SlippageManager/SlippageManager.tsx +++ b/src/components/SlippageManager/SlippageManager.tsx @@ -38,7 +38,8 @@ const SlippageManager = forwardRef( diff --git a/src/components/index.tsx b/src/components/index.tsx index 800c588f63..64d6da9ad3 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -6,6 +6,8 @@ export type { AuthCTAProps } from './AuthCTA'; export { AuthCTA } from './AuthCTA'; export type { AuthModalProps, SignTermsModalProps } from './AuthModal'; export { AuthModal, SignTermsModal } from './AuthModal'; +export type { ClaimModalProps } from './ClaimModal'; +export { ClaimModal } from './ClaimModal'; export type { AssetCellProps, BalanceCellProps, CellProps, TableProps } from './DataGrid'; export { AssetCell, BalanceCell, Cell, Table } from './DataGrid'; export type { FundWalletProps } from './FundWallet'; diff --git a/src/hooks/api/escrow/use-get-account-claimable-rewards.tsx b/src/hooks/api/escrow/use-get-account-claimable-rewards.tsx new file mode 100644 index 0000000000..1617c3174f --- /dev/null +++ b/src/hooks/api/escrow/use-get-account-claimable-rewards.tsx @@ -0,0 +1,36 @@ +import { CurrencyExt } from '@interlay/interbtc-api'; +import { MonetaryAmount } from '@interlay/monetary-js'; +import { AccountId } from '@polkadot/types/interfaces'; +import { useErrorHandler } from 'react-error-boundary'; +import { useQuery } from 'react-query'; + +import { useWallet } from '@/hooks/use-wallet'; +import { REFETCH_INTERVAL } from '@/utils/constants/api'; + +const getAccountStakingData = async (accountId: AccountId) => window.bridge.escrow.getRewards(accountId); + +interface UseGetAccountStakingClaimableRewardsResult { + data: MonetaryAmount | undefined; + isLoading: boolean; + refetch: () => void; +} + +const useGetAccountStakingClaimableRewards = (): UseGetAccountStakingClaimableRewardsResult => { + const { account } = useWallet(); + + const queryKey = ['staking-claimable-rewards', account]; + + const { data, error, isLoading, refetch } = useQuery({ + queryKey, + queryFn: () => account && getAccountStakingData(account), + refetchInterval: REFETCH_INTERVAL.BLOCK, + enabled: !!account + }); + + useErrorHandler(error); + + return { data, isLoading, refetch }; +}; + +export { useGetAccountStakingClaimableRewards }; +export type { UseGetAccountStakingClaimableRewardsResult }; diff --git a/src/hooks/api/escrow/use-get-account-staking-data.tsx b/src/hooks/api/escrow/use-get-account-staking-data.tsx index 31aebac5d7..289dd4b7f7 100644 --- a/src/hooks/api/escrow/use-get-account-staking-data.tsx +++ b/src/hooks/api/escrow/use-get-account-staking-data.tsx @@ -1,71 +1,103 @@ -import { CurrencyExt } from '@interlay/interbtc-api'; +import { CurrencyExt, newMonetaryAmount } from '@interlay/interbtc-api'; import { MonetaryAmount } from '@interlay/monetary-js'; import { AccountId } from '@polkadot/types/interfaces'; +import Big from 'big.js'; import { add } from 'date-fns'; import { useErrorHandler } from 'react-error-boundary'; import { useQuery } from 'react-query'; import { BLOCK_TIME } from '@/config/parachain'; -import { BLOCKTIME_REFETCH_INTERVAL } from '@/utils/constants/api'; +import { GOVERNANCE_TOKEN } from '@/config/relay-chains'; +import { REFETCH_INTERVAL } from '@/utils/constants/api'; import useAccountId from '../../use-account-id'; type AccountUnlockStakingData = { date: Date; block: number; + remainingBlocks: number; + isAvailable: boolean; }; -type GetAccountStakingData = { +type AccountStakingData = { unlock: AccountUnlockStakingData; balance: MonetaryAmount; + votingBalance: MonetaryAmount; + projected: { + amount: MonetaryAmount; + apy: Big; + }; + limit: MonetaryAmount; }; const getUnlockData = (stakeEndBlock: number, currentBlockNumber: number): AccountUnlockStakingData => { - const blocksUntilUnlockDate = stakeEndBlock - currentBlockNumber; + const remainingBlocks = stakeEndBlock - currentBlockNumber; - const unlockDate = add(new Date(), { seconds: blocksUntilUnlockDate * BLOCK_TIME }); + const unlockDate = add(new Date(), { seconds: remainingBlocks * BLOCK_TIME }); return { date: unlockDate, - block: stakeEndBlock + block: stakeEndBlock, + remainingBlocks, + isAvailable: remainingBlocks <= 0 }; }; -const getAccountStakingData = async (accountId: AccountId): Promise => { - const stakedBalancePromise = window.bridge.escrow.getStakedBalance(accountId); +const getAccountStakingData = async (accountId: AccountId): Promise => { + const stakedBalance = await window.bridge.escrow.getStakedBalance(accountId); + + if (stakedBalance.amount.isZero()) { + return null; + } + + const limitPromise = window.bridge.api.rpc.escrow.freeStakable(accountId); const currentBlockNumberPromise = window.bridge.system.getCurrentBlockNumber(); + const projectedPromise = window.bridge.escrow.getRewardEstimate(accountId); + const votingBalancePromise = window.bridge.escrow.votingBalance(accountId); + + const [limit, currentBlockNumber, projected, votingBalance] = await Promise.all([ + limitPromise, + currentBlockNumberPromise, + projectedPromise, + votingBalancePromise + ]); - const [stakedBalance, currentBlockNumber] = await Promise.all([stakedBalancePromise, currentBlockNumberPromise]); + const unparsedLimit = limit?.values().next().value?.toString() as string; + const parsedLimit = newMonetaryAmount(unparsedLimit || 0, GOVERNANCE_TOKEN); const unlock = getUnlockData(stakedBalance.endBlock, currentBlockNumber); return { unlock, - balance: stakedBalance.amount + balance: stakedBalance.amount, + votingBalance, + projected, + limit: parsedLimit }; }; -interface GetAccountStakingDataResult { - data: GetAccountStakingData | undefined; +interface UseGetAccountStakingDataResult { + data: AccountStakingData | null | undefined; + isLoading: boolean; refetch: () => void; } -const useGetAccountStakingData = (): GetAccountStakingDataResult => { +const useGetAccountStakingData = (): UseGetAccountStakingDataResult => { const accountId = useAccountId(); const queryKey = ['staking', accountId]; - const { data, error, refetch } = useQuery({ + const { data, error, isLoading, refetch } = useQuery({ queryKey, queryFn: () => accountId && getAccountStakingData(accountId), - refetchInterval: BLOCKTIME_REFETCH_INTERVAL, + refetchInterval: REFETCH_INTERVAL.BLOCK, enabled: !!accountId }); useErrorHandler(error); - return { data, refetch }; + return { data, isLoading, refetch }; }; export { useGetAccountStakingData }; -export type { AccountUnlockStakingData, GetAccountStakingData, GetAccountStakingDataResult }; +export type { AccountStakingData, AccountUnlockStakingData, UseGetAccountStakingDataResult }; diff --git a/src/hooks/api/escrow/use-get-account-voting-balance.tsx b/src/hooks/api/escrow/use-get-account-voting-balance.tsx deleted file mode 100644 index 649e79e61c..0000000000 --- a/src/hooks/api/escrow/use-get-account-voting-balance.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { CurrencyExt } from '@interlay/interbtc-api'; -import { MonetaryAmount } from '@interlay/monetary-js'; -import { AccountId } from '@polkadot/types/interfaces'; -import { useErrorHandler } from 'react-error-boundary'; -import { useQuery } from 'react-query'; - -import { BLOCKTIME_REFETCH_INTERVAL } from '@/utils/constants/api'; - -import useAccountId from '../../use-account-id'; - -const getAccountVotingBalance = async (accountId: AccountId): Promise> => - window.bridge.escrow.votingBalance(accountId); - -interface GetAccountVotingBalanceResult { - data: MonetaryAmount | undefined; - refetch: () => void; -} - -const useGetAccountVotingBalance = (): GetAccountVotingBalanceResult => { - const accountId = useAccountId(); - - const queryKey = ['voting', accountId]; - - const { data, error, refetch } = useQuery({ - queryKey, - queryFn: () => accountId && getAccountVotingBalance(accountId), - refetchInterval: BLOCKTIME_REFETCH_INTERVAL, - enabled: !!accountId - }); - - useErrorHandler(error); - - return { data, refetch }; -}; - -export { useGetAccountVotingBalance }; -export type { GetAccountVotingBalanceResult }; diff --git a/src/hooks/api/escrow/use-get-staking-details-data.tsx b/src/hooks/api/escrow/use-get-staking-details-data.tsx new file mode 100644 index 0000000000..75b076b309 --- /dev/null +++ b/src/hooks/api/escrow/use-get-staking-details-data.tsx @@ -0,0 +1,101 @@ +import { CurrencyExt, newMonetaryAmount } from '@interlay/interbtc-api'; +import { MonetaryAmount } from '@interlay/monetary-js'; +import { AccountId } from '@polkadot/types/interfaces'; +import Big from 'big.js'; +import { add } from 'date-fns'; +import { useRef } from 'react'; +import { MutationFunction, useMutation, UseMutationOptions, UseMutationResult } from 'react-query'; + +import { GOVERNANCE_TOKEN, STAKE_LOCK_TIME } from '@/config/relay-chains'; +import { convertBlockNumbersToWeeks, convertWeeksToBlockNumbers } from '@/utils/helpers/staking'; + +import useAccountId from '../../use-account-id'; +import { AccountStakingData, useGetAccountStakingData } from './use-get-account-staking-data'; + +type AccountStakingDetailsData = { + apy?: Big; + totalStaked: MonetaryAmount; + votingBalanceGained: MonetaryAmount; + governanceBalanceReward?: MonetaryAmount; + date: Date; +}; + +const getDetailsData = async ( + accountId?: AccountId, + accountData?: AccountStakingData | null, + amount: MonetaryAmount = newMonetaryAmount(0, GOVERNANCE_TOKEN), + weeksLocked = 0 +): Promise => { + const baseBlockNumber = accountData?.unlock.block || (await window.bridge.system.getCurrentBlockNumber()); + + const newBlockNumber = baseBlockNumber + convertWeeksToBlockNumbers(weeksLocked); + + const existingWeeksLocked = accountData ? convertBlockNumbersToWeeks(accountData.unlock.remainingBlocks) : 0; + + const totalWeeksLocked = existingWeeksLocked + weeksLocked; + + const totalStakedAmount = accountData ? accountData.balance.add(amount) : amount; + + const totalStaked = totalStakedAmount.mul(totalWeeksLocked).div(STAKE_LOCK_TIME.MAX); + + const votingBalanceGained = accountData ? totalStaked.sub(accountData?.votingBalance) : totalStaked; + + const { amount: governanceBalanceReward, apy } = accountId + ? await window.bridge.escrow.getRewardEstimate(accountId, amount, newBlockNumber) + : { amount: undefined, apy: undefined }; + + const date = add(accountData?.unlock.date || new Date(), { + weeks: weeksLocked + }); + + return { + apy, + totalStaked, + votingBalanceGained, + governanceBalanceReward, + date + }; +}; + +type StakingEstimationVariables = { + amount?: MonetaryAmount; + weeksLocked?: number; +}; + +type UseGetStakingEstimationOptions = UseMutationOptions< + AccountStakingDetailsData, + Error, + StakingEstimationVariables, + unknown +>; + +type UseGetAccountStakingDetailsDataResult = UseMutationResult< + AccountStakingDetailsData, + Error, + StakingEstimationVariables, + unknown +>; + +const useGetStakingDetailsData = (options?: UseGetStakingEstimationOptions): UseGetAccountStakingDetailsDataResult => { + const accountId = useAccountId(); + const resultRef = useRef(); + + const accountData = useGetAccountStakingData(); + + const fn: MutationFunction = ({ + amount, + weeksLocked + }) => getDetailsData(accountId, accountData.data, amount, weeksLocked); + + const mutation = useMutation(fn, { + ...options, + onSuccess: (data) => { + resultRef.current = data; + } + }); + + return { ...mutation, data: resultRef.current } as UseGetAccountStakingDetailsDataResult; +}; + +export { useGetStakingDetailsData }; +export type { AccountStakingDetailsData, UseGetAccountStakingDetailsDataResult }; diff --git a/src/hooks/api/escrow/uset-get-network-staking-data.tsx b/src/hooks/api/escrow/uset-get-network-staking-data.tsx new file mode 100644 index 0000000000..520a73062f --- /dev/null +++ b/src/hooks/api/escrow/uset-get-network-staking-data.tsx @@ -0,0 +1,46 @@ +import { CurrencyExt } from '@interlay/interbtc-api'; +import { MonetaryAmount } from '@interlay/monetary-js'; +import { useErrorHandler } from 'react-error-boundary'; +import { useQuery } from 'react-query'; + +import { REFETCH_INTERVAL } from '@/utils/constants/api'; + +type NetworkStakingData = { + totalVotingSupply: MonetaryAmount; + totalStakedBalance: MonetaryAmount; +}; + +const getNetworkStakingData = async (): Promise => { + const totalVotingSupplyPromise = window.bridge.escrow.totalVotingSupply(); + const totalStakedBalancePromise = window.bridge.escrow.getTotalStakedBalance(); + + const [totalVotingSupply, totalStakedBalance] = await Promise.all([ + totalVotingSupplyPromise, + totalStakedBalancePromise + ]); + + return { + totalVotingSupply, + totalStakedBalance + }; +}; + +interface GetNetworkStakingDataResult { + data: NetworkStakingData | undefined; + refetch: () => void; +} + +const useGetNetworkStakingData = (): GetNetworkStakingDataResult => { + const { data, error, refetch } = useQuery({ + queryKey: 'network-staking-data', + queryFn: getNetworkStakingData, + refetchInterval: REFETCH_INTERVAL.BLOCK + }); + + useErrorHandler(error); + + return { data, refetch }; +}; + +export { useGetNetworkStakingData }; +export type { GetNetworkStakingDataResult, NetworkStakingData }; diff --git a/src/hooks/transaction/extrinsics/lib.ts b/src/hooks/transaction/extrinsics/lib.ts index 4e8d09ad4f..375f4cbf74 100644 --- a/src/hooks/transaction/extrinsics/lib.ts +++ b/src/hooks/transaction/extrinsics/lib.ts @@ -203,9 +203,10 @@ const getLibExtrinsic = async (params: LibActions): Promise => { return window.bridge.escrow.withdrawRewards(...params.args); case Transaction.ESCROW_INCREASE_LOOKED_TIME_AND_AMOUNT: { const [amount, unlockHeight] = params.args; + const txs = [ - window.bridge.api.tx.escrow.increaseAmount(amount), - window.bridge.api.tx.escrow.increaseUnlockHeight(unlockHeight) + window.bridge.escrow.increaseAmount(amount).extrinsic, + window.bridge.escrow.increaseUnlockHeight(unlockHeight).extrinsic ]; const batch = window.bridge.api.tx.utility.batchAll(txs); diff --git a/src/hooks/transaction/types/escrow.ts b/src/hooks/transaction/types/escrow.ts index 211cfc762d..bd8c2213c0 100644 --- a/src/hooks/transaction/types/escrow.ts +++ b/src/hooks/transaction/types/escrow.ts @@ -10,8 +10,8 @@ interface EscrowCreateLockAction { interface EscrowInscreaseLookedTimeAndAmountAction { type: Transaction.ESCROW_INCREASE_LOOKED_TIME_AND_AMOUNT; args: [ - ...Parameters, - ...Parameters + ...Parameters, + ...Parameters ]; } interface EscrowIncreaseLockAmountAction { diff --git a/src/hooks/transaction/utils/fee.ts b/src/hooks/transaction/utils/fee.ts index fdb3e311e8..74b07ebe1f 100644 --- a/src/hooks/transaction/utils/fee.ts +++ b/src/hooks/transaction/utils/fee.ts @@ -159,6 +159,20 @@ const getAmount = (params: Actions): MonetaryAmount[] | undefined = return [calculatedLimit]; } /* END - LOANS */ + /* START - ESCROW */ + case Transaction.ESCROW_CREATE_LOCK: { + const [amount] = params.args; + return [amount]; + } + case Transaction.ESCROW_INCREASE_LOOKED_TIME_AND_AMOUNT: { + const [amount] = params.args; + return [amount]; + } + case Transaction.ESCROW_INCREASE_LOCKED_AMOUNT: { + const [amount] = params.args; + return [amount]; + } + /* END - ESCROW */ case Transaction.STRATEGIES_DEPOSIT: { const [, , isIdentitySet, , amount] = params.args; if (isIdentitySet) { @@ -184,6 +198,9 @@ const getAmount = (params: Actions): MonetaryAmount[] | undefined = case Transaction.STRATEGIES_WITHDRAW: case Transaction.STRATEGIES_INITIALIZE_PROXY: case Transaction.AMM_CLAIM_REWARDS: + case Transaction.ESCROW_INCREASE_LOCKED_TIME: + case Transaction.ESCROW_WITHDRAW: + case Transaction.ESCROW_WITHDRAW_REWARDS: return undefined; } diff --git a/src/lib/form/schemas/index.ts b/src/lib/form/schemas/index.ts index 9c812b09d6..06d05adcb3 100644 --- a/src/lib/form/schemas/index.ts +++ b/src/lib/form/schemas/index.ts @@ -1,6 +1,7 @@ export * from './btc'; export * from './loans'; export * from './pools'; +export * from './staking'; export * from './strategies'; export * from './swap'; export * from './transfers'; diff --git a/src/lib/form/schemas/staking.ts b/src/lib/form/schemas/staking.ts new file mode 100644 index 0000000000..649a4810ae --- /dev/null +++ b/src/lib/form/schemas/staking.ts @@ -0,0 +1,56 @@ +import i18n from 'i18next'; + +import yup, { MaxAmountValidationParams, MinAmountValidationParams } from '../yup.custom'; + +const STAKING_AMOUNT_FIELD = 'staking-amount'; +const STAKING_LOCKED_WEEKS_AMOUNT_FIELD = 'staking-locked-weeks-amount'; +const STAKING_FEE_TOKEN_FIELD = 'staking-fee-token'; + +type StakingFormData = { + [STAKING_AMOUNT_FIELD]?: string; + [STAKING_LOCKED_WEEKS_AMOUNT_FIELD]?: string; + [STAKING_FEE_TOKEN_FIELD]?: string; +}; + +type StakingValidationParams = { + [STAKING_AMOUNT_FIELD]: Partial & Partial; + [STAKING_LOCKED_WEEKS_AMOUNT_FIELD]: { + min: number; + max: number; + }; +}; + +const stakingSchema = (params: StakingValidationParams, hasStaked: boolean): yup.ObjectSchema => { + const amountParams = params[STAKING_AMOUNT_FIELD]; + const { min, max } = params[STAKING_LOCKED_WEEKS_AMOUNT_FIELD]; + + let baseAmountSchema = yup + .string() + .maxAmount(amountParams as MaxAmountValidationParams) + .minAmount(amountParams as MinAmountValidationParams, 'transfer'); + + let baseLockTimeSchema = yup + .string() + .test('max', `Lock time must be less than or equal to ${max}`, (value) => + value === undefined ? true : Number(value) <= max + ); + + if (!hasStaked) { + baseAmountSchema = baseAmountSchema.requiredAmount('stake'); + + baseLockTimeSchema = baseLockTimeSchema + .required(i18n.t('forms.please_enter_your_field', { field: 'lock time' })) + .test('min', `Lock time must be greater than or equal to ${min}`, (value) => + value === undefined ? true : Number(value) >= min + ); + } + + return yup.object().shape({ + [STAKING_AMOUNT_FIELD]: baseAmountSchema, + [STAKING_LOCKED_WEEKS_AMOUNT_FIELD]: baseLockTimeSchema, + [STAKING_FEE_TOKEN_FIELD]: yup.string().required() + }); +}; + +export { STAKING_AMOUNT_FIELD, STAKING_FEE_TOKEN_FIELD, STAKING_LOCKED_WEEKS_AMOUNT_FIELD, stakingSchema }; +export type { StakingFormData, StakingValidationParams }; diff --git a/src/pages/Staking/BalancesUI/index.tsx b/src/pages/Staking/BalancesUI/index.tsx deleted file mode 100644 index 977e951896..0000000000 --- a/src/pages/Staking/BalancesUI/index.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import clsx from 'clsx'; -import { useTranslation } from 'react-i18next'; - -import { GOVERNANCE_TOKEN_SYMBOL, VOTE_GOVERNANCE_TOKEN_SYMBOL } from '@/config/relay-chains'; -import InformationTooltip from '@/legacy-components/tooltips/InformationTooltip'; -import { KUSAMA, POLKADOT } from '@/utils/constants/relay-chain-names'; - -const Label = ({ className, ...rest }: React.ComponentPropsWithRef<'span'>) => ( - -); - -interface AmountCustomProps { - value: string; - tokenSymbol: string; -} - -const Amount = ({ className, value, tokenSymbol, ...rest }: AmountCustomProps & React.ComponentPropsWithRef<'div'>) => ( -
- {value} - {tokenSymbol} -
-); - -interface BalanceItemCustomProps { - label: string; - value: string; - tokenSymbol: string; - tooltip?: string; -} - -const BalanceItem = ({ - label, - value, - tokenSymbol, - tooltip, - ...rest -}: BalanceItemCustomProps & React.ComponentPropsWithRef<'div'>) => ( -
-
- - {tooltip && } -
- -
-); - -interface Props { - stakedAmount: string; - voteStakedAmount: string; - projectedRewardAmount: string; -} - -const BalancesUI = ({ stakedAmount, voteStakedAmount, projectedRewardAmount }: Props): JSX.Element => { - const { t } = useTranslation(); - - return ( -
- - - -
- ); -}; - -export default BalancesUI; diff --git a/src/pages/Staking/ClaimRewardsButton/index.tsx b/src/pages/Staking/ClaimRewardsButton/index.tsx deleted file mode 100644 index 80fb00983c..0000000000 --- a/src/pages/Staking/ClaimRewardsButton/index.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import clsx from 'clsx'; -import { useQueryClient } from 'react-query'; - -import { GOVERNANCE_TOKEN_SYMBOL } from '@/config/relay-chains'; -import { Transaction, useTransaction } from '@/hooks/transaction'; -import InterlayDenimOrKintsugiSupernovaContainedButton, { - Props as InterlayDenimOrKintsugiMidnightContainedButtonProps -} from '@/legacy-components/buttons/InterlayDenimOrKintsugiSupernovaContainedButton'; -import { useSubstrateSecureState } from '@/lib/substrate'; -import { GENERIC_FETCHER } from '@/services/fetchers/generic-fetcher'; - -interface CustomProps { - claimableRewardAmount: string; -} - -const ClaimRewardsButton = ({ - className, - claimableRewardAmount, - ...rest -}: CustomProps & InterlayDenimOrKintsugiMidnightContainedButtonProps): JSX.Element => { - const { selectedAccount } = useSubstrateSecureState(); - - const queryClient = useQueryClient(); - - const transaction = useTransaction(Transaction.ESCROW_WITHDRAW_REWARDS, { - onSuccess: () => { - queryClient.invalidateQueries([GENERIC_FETCHER, 'escrow', 'getRewardEstimate', selectedAccount?.address]); - queryClient.invalidateQueries([GENERIC_FETCHER, 'escrow', 'getRewards', selectedAccount?.address]); - } - }); - - const handleClaimRewards = () => { - transaction.execute(); - }; - - return ( - - Claim {claimableRewardAmount} {GOVERNANCE_TOKEN_SYMBOL} Rewards - - ); -}; - -export default ClaimRewardsButton; diff --git a/src/pages/Staking/InformationUI/index.tsx b/src/pages/Staking/InformationUI/index.tsx deleted file mode 100644 index f9e081eaa8..0000000000 --- a/src/pages/Staking/InformationUI/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import clsx from 'clsx'; - -import InformationTooltip from '@/legacy-components/tooltips/InformationTooltip'; -import { KUSAMA, POLKADOT } from '@/utils/constants/relay-chain-names'; - -interface CustomProps { - label: string; - value: string | number; - tooltip?: string; -} - -const InformationUI = ({ - label, - value, - tooltip, - className, - ...rest -}: CustomProps & React.ComponentPropsWithRef<'div'>): JSX.Element => { - return ( -
-
- {label} - {tooltip && } -
- - {value} - -
- ); -}; - -export default InformationUI; diff --git a/src/pages/Staking/LockTimeField/index.tsx b/src/pages/Staking/LockTimeField/index.tsx deleted file mode 100644 index 1e55b5f43c..0000000000 --- a/src/pages/Staking/LockTimeField/index.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import clsx from 'clsx'; -import * as React from 'react'; - -import { STAKE_LOCK_TIME } from '@/config/relay-chains'; -import NumberInput, { Props as NumberInputProps } from '@/legacy-components/NumberInput'; -import { TextFieldHelperText, TextFieldLabel } from '@/legacy-components/TextField'; -import { KUSAMA, POLKADOT } from '@/utils/constants/relay-chain-names'; - -// MEMO: inspired by https://medium.com/codex/making-html-5-numeric-inputs-only-accept-integers-d3d117973d56 -const integerRegexPattern = /\d/; -const handleLockTimeChange = (event: KeyboardEvent) => { - if (event.key.length > 1 || integerRegexPattern.test(event.key)) return; - - event.preventDefault(); -}; - -const LABEL_TEXT_COLOR_CLASSES = clsx( - { 'text-interlayTextSecondaryInLightMode': process.env.REACT_APP_RELAY_CHAIN_NAME === POLKADOT }, - { 'dark:text-kintsugiTextSecondaryInDarkMode': process.env.REACT_APP_RELAY_CHAIN_NAME === KUSAMA } -); - -interface CustomProps { - optional?: boolean; - error?: boolean; - helperText?: JSX.Element | string; -} - -type Ref = HTMLInputElement; -const LockTimeField = React.forwardRef( - ({ optional, id, name, error, helperText, ...rest }, ref): JSX.Element => { - const wrappingRef = React.useRef(null); - - React.useEffect(() => { - if (!wrappingRef) return; - if (!wrappingRef.current) return; - - const wrappingRefCurrent = wrappingRef.current; - - wrappingRefCurrent.addEventListener('keydown', handleLockTimeChange); - - return () => { - wrappingRefCurrent.removeEventListener('keydown', handleLockTimeChange); - }; - }, []); - - return ( -
- - Total {STAKE_LOCK_TIME.MAX} Weeks - -
- {optional === true && ( -
- Extend lock time in weeks - (Optional): -
- )} - {optional === false && Choose lock time in weeks:} - {optional === undefined && Checking...} -
- - Weeks -
-
- - {helperText} - -
- ); - } -); -LockTimeField.displayName = 'LockTimeField'; - -export default LockTimeField; diff --git a/src/pages/Staking/Staking.style.tsx b/src/pages/Staking/Staking.style.tsx new file mode 100644 index 0000000000..894f9c0c54 --- /dev/null +++ b/src/pages/Staking/Staking.style.tsx @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +import { Flex, theme } from '@/component-library'; + +import { StakingForm } from './components'; + +const StyledWrapper = styled(Flex)` + width: 100%; + max-width: 840px; + margin: 0 auto; + flex-direction: column-reverse; + + @media ${theme.breakpoints.up('lg')} { + flex-direction: row; + } +`; + +const StyledStakingForm = styled(StakingForm)` + width: 100%; + flex: 1 1 540px; + + @media ${theme.breakpoints.up('lg')} { + min-width: 540px; + } +`; + +const StyledStakingDetails = styled(Flex)` + min-width: 290px; +`; + +export { StyledStakingDetails, StyledStakingForm, StyledWrapper }; diff --git a/src/pages/Staking/Staking.tsx b/src/pages/Staking/Staking.tsx new file mode 100644 index 0000000000..b6bdadf281 --- /dev/null +++ b/src/pages/Staking/Staking.tsx @@ -0,0 +1,49 @@ +import { MainContainer } from '@/components'; +import { useGetAccountStakingClaimableRewards } from '@/hooks/api/escrow/use-get-account-claimable-rewards'; +import { useGetAccountStakingData } from '@/hooks/api/escrow/use-get-account-staking-data'; +import { useGetNetworkStakingData } from '@/hooks/api/escrow/uset-get-network-staking-data'; +import FullLoadingSpinner from '@/legacy-components/FullLoadingSpinner'; + +import { StakingAccountDetails } from './components'; +import { StakingWithdrawCard } from './components/StakingWithdrawCard'; +import { StyledStakingDetails, StyledStakingForm, StyledWrapper } from './Staking.style'; + +const Staking = (): JSX.Element => { + const { + data: accountData, + refetch: refetchAccountData, + isLoading: isAccountStakingDataLoading + } = useGetAccountStakingData(); + const { + data: claimableRewards, + refetch: refetchClaimableRewards, + isLoading: isClaimableRewardsLoading + } = useGetAccountStakingClaimableRewards(); + const { data: networkData } = useGetNetworkStakingData(); + + if ( + (isAccountStakingDataLoading && accountData === undefined) || + (isClaimableRewardsLoading && claimableRewards === undefined) || + networkData === undefined + ) { + return ; + } + + return ( + + + + + + {accountData && } + + + + ); +}; + +export default Staking; diff --git a/src/pages/Staking/TotalsUI/index.tsx b/src/pages/Staking/TotalsUI/index.tsx deleted file mode 100644 index 1a2ac6ed8b..0000000000 --- a/src/pages/Staking/TotalsUI/index.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useErrorHandler, withErrorBoundary } from 'react-error-boundary'; -import { useTranslation } from 'react-i18next'; -import { useQuery } from 'react-query'; -import { useSelector } from 'react-redux'; - -import { StoreType } from '@/common/types/util.types'; -import { displayMonetaryAmount } from '@/common/utils/utils'; -import { - GOVERNANCE_TOKEN_SYMBOL, - GovernanceTokenMonetaryAmount, - VOTE_GOVERNANCE_TOKEN_SYMBOL, - VoteGovernanceTokenMonetaryAmount -} from '@/config/relay-chains'; -import ErrorFallback from '@/legacy-components/ErrorFallback'; -import genericFetcher, { GENERIC_FETCHER } from '@/services/fetchers/generic-fetcher'; - -import InformationUI from '../InformationUI'; - -const TotalsUI = (): JSX.Element => { - const { bridgeLoaded } = useSelector((state: StoreType) => state.general); - - const { t } = useTranslation(); - - const { - isIdle: totalVoteGovernanceTokenAmountIdle, - isLoading: totalVoteGovernanceTokenAmountLoading, - data: totalVoteGovernanceTokenAmount, - error: totalVoteGovernanceTokenAmountError - } = useQuery( - [GENERIC_FETCHER, 'escrow', 'totalVotingSupply'], - genericFetcher(), - { - enabled: !!bridgeLoaded - } - ); - useErrorHandler(totalVoteGovernanceTokenAmountError); - - const { - isIdle: totalStakedGovernanceTokenAmountIdle, - isLoading: totalStakedGovernanceTokenAmountLoading, - data: totalStakedGovernanceTokenAmount, - error: totalStakedGovernanceTokenAmountError - } = useQuery( - [GENERIC_FETCHER, 'escrow', 'getTotalStakedBalance'], - genericFetcher(), - { - enabled: !!bridgeLoaded - } - ); - useErrorHandler(totalStakedGovernanceTokenAmountError); - - let totalVoteGovernanceTokenAmountLabel; - if (totalVoteGovernanceTokenAmountIdle || totalVoteGovernanceTokenAmountLoading) { - totalVoteGovernanceTokenAmountLabel = '-'; - } else { - if (totalVoteGovernanceTokenAmount === undefined) { - throw new Error('Something went wrong!'); - } - totalVoteGovernanceTokenAmountLabel = `${displayMonetaryAmount( - totalVoteGovernanceTokenAmount - )} ${VOTE_GOVERNANCE_TOKEN_SYMBOL}`; - } - - let totalStakedGovernanceTokenAmountLabel; - if (totalStakedGovernanceTokenAmountIdle || totalStakedGovernanceTokenAmountLoading) { - totalStakedGovernanceTokenAmountLabel = '-'; - } else { - if (totalStakedGovernanceTokenAmount === undefined) { - throw new Error('Something went wrong!'); - } - totalStakedGovernanceTokenAmountLabel = `${displayMonetaryAmount( - totalStakedGovernanceTokenAmount - )} ${GOVERNANCE_TOKEN_SYMBOL}`; - } - - return ( -
- - -
- ); -}; - -export default withErrorBoundary(TotalsUI, { - FallbackComponent: ErrorFallback, - onReset: () => { - window.location.reload(); - } -}); diff --git a/src/pages/Staking/WithdrawButton/index.tsx b/src/pages/Staking/WithdrawButton/index.tsx deleted file mode 100644 index 1dfa40bfc5..0000000000 --- a/src/pages/Staking/WithdrawButton/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import clsx from 'clsx'; -import { add, format } from 'date-fns'; -import { useQueryClient } from 'react-query'; - -import { BLOCK_TIME } from '@/config/parachain'; -import { GOVERNANCE_TOKEN_SYMBOL } from '@/config/relay-chains'; -import { Transaction, useTransaction } from '@/hooks/transaction'; -import InterlayDenimOrKintsugiSupernovaContainedButton, { - Props as InterlayDenimOrKintsugiMidnightContainedButtonProps -} from '@/legacy-components/buttons/InterlayDenimOrKintsugiSupernovaContainedButton'; -import InformationTooltip from '@/legacy-components/tooltips/InformationTooltip'; -import { useSubstrateSecureState } from '@/lib/substrate'; -import { GENERIC_FETCHER } from '@/services/fetchers/generic-fetcher'; -import { YEAR_MONTH_DAY_PATTERN } from '@/utils/constants/date-time'; - -const getFormattedUnlockDate = (remainingBlockNumbersToUnstake: number, formatPattern: string) => { - const unlockDate = add(new Date(), { - seconds: remainingBlockNumbersToUnstake * BLOCK_TIME - }); - - return format(unlockDate, formatPattern); -}; - -interface CustomProps { - stakedAmount: string; - remainingBlockNumbersToUnstake: number | undefined; -} - -const WithdrawButton = ({ - className, - stakedAmount, - remainingBlockNumbersToUnstake, - ...rest -}: CustomProps & InterlayDenimOrKintsugiMidnightContainedButtonProps): JSX.Element => { - const { selectedAccount } = useSubstrateSecureState(); - - const transaction = useTransaction(Transaction.ESCROW_WITHDRAW, { - onSuccess: () => { - queryClient.invalidateQueries([GENERIC_FETCHER, 'escrow', 'getStakedBalance', selectedAccount?.address]); - } - }); - - const queryClient = useQueryClient(); - - const handleUnstake = () => transaction.execute(); - - const disabled = remainingBlockNumbersToUnstake ? remainingBlockNumbersToUnstake > 0 : false; - - const renderUnlockDateLabel = () => { - return remainingBlockNumbersToUnstake === undefined - ? '-' - : getFormattedUnlockDate(remainingBlockNumbersToUnstake, YEAR_MONTH_DAY_PATTERN); - }; - - const renderUnlockDateTimeLabel = () => { - return remainingBlockNumbersToUnstake === undefined - ? '-' - : getFormattedUnlockDate(remainingBlockNumbersToUnstake, 'PPpp'); - }; - - return ( - <> - - } - onClick={handleUnstake} - pending={transaction.isLoading} - disabled={disabled} - {...rest} - > - Withdraw Staked {GOVERNANCE_TOKEN_SYMBOL} {renderUnlockDateLabel()} - - - ); -}; - -export default WithdrawButton; diff --git a/src/pages/Staking/components/StakingAccountDetails/StakingAccountDetails.tsx b/src/pages/Staking/components/StakingAccountDetails/StakingAccountDetails.tsx new file mode 100644 index 0000000000..170a209e17 --- /dev/null +++ b/src/pages/Staking/components/StakingAccountDetails/StakingAccountDetails.tsx @@ -0,0 +1,100 @@ +import { CurrencyExt } from '@interlay/interbtc-api'; +import { MonetaryAmount } from '@interlay/monetary-js'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Card, CardProps, Dd, Divider, Dl, DlGroup, Dt } from '@/component-library'; +import { AuthCTA, ClaimModal, IsAuthenticated } from '@/components'; +import { GOVERNANCE_TOKEN, VOTE_GOVERNANCE_TOKEN } from '@/config/relay-chains'; +import { AccountStakingData } from '@/hooks/api/escrow/use-get-account-staking-data'; +import { Transaction, useTransaction } from '@/hooks/transaction'; + +type Props = { + accountData?: AccountStakingData | null; + claimableRewards?: MonetaryAmount; + onClaimRewards: () => void; +}; + +type InheritAttrs = CardProps & Props; + +type StakingAccountDetailsProps = Props & InheritAttrs; + +const StakingAccountDetails = ({ + accountData, + claimableRewards, + onClaimRewards, + ...props +}: StakingAccountDetailsProps): JSX.Element => { + const { t } = useTranslation(); + const [isOpen, setOpen] = useState(false); + + const transaction = useTransaction(Transaction.ESCROW_WITHDRAW_REWARDS, { + onSuccess: () => { + onClaimRewards(); + setOpen(false); + } + }); + + const handleSubmit = () => transaction.execute(); + + const handleOpen = () => transaction.fee.estimate(); + + const handlePress = () => setOpen(true); + + const { votingBalance, balance, projected } = accountData || {}; + + const hasClaimableRewards = !claimableRewards?.isZero(); + + return ( + <> + +
+ +
{t('staking_page.staked_ticker', { ticker: GOVERNANCE_TOKEN.ticker })}
+
+ {balance?.toHuman() || 0} +
+
+ +
{t('ticker_balance', { ticker: VOTE_GOVERNANCE_TOKEN.ticker })}
+
+ {votingBalance?.toHuman() || 0} +
+
+ +
{t('staking_page.projected_ticker_rewards', { ticker: GOVERNANCE_TOKEN.ticker })}
+
+ {projected?.amount.toHuman() || 0} +
+
+ + +
{t('claimable_rewards')}
+
+ {claimableRewards?.toHuman() || 0} {GOVERNANCE_TOKEN.ticker} +
+
+
+ {hasClaimableRewards && ( + + + {t('claim')} + + + )} +
+ setOpen(false)} + title={t('claim_rewards')} + submitLabel={t('claim')} + transaction={transaction} + onSubmit={handleSubmit} + onOpen={handleOpen} + /> + + ); +}; + +export { StakingAccountDetails }; +export type { StakingAccountDetailsProps }; diff --git a/src/pages/Staking/components/StakingAccountDetails/index.tsx b/src/pages/Staking/components/StakingAccountDetails/index.tsx new file mode 100644 index 0000000000..0491dc8648 --- /dev/null +++ b/src/pages/Staking/components/StakingAccountDetails/index.tsx @@ -0,0 +1,2 @@ +export type { StakingAccountDetailsProps } from './StakingAccountDetails'; +export { StakingAccountDetails } from './StakingAccountDetails'; diff --git a/src/pages/Staking/components/StakingForm/StakingForm.style.tsx b/src/pages/Staking/components/StakingForm/StakingForm.style.tsx new file mode 100644 index 0000000000..b6d14ced7f --- /dev/null +++ b/src/pages/Staking/components/StakingForm/StakingForm.style.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +import { List, theme } from '@/component-library'; + +const StyledList = styled(List)` + font-size: ${theme.text.xs}; +`; + +export { StyledList }; diff --git a/src/pages/Staking/components/StakingForm/StakingForm.tsx b/src/pages/Staking/components/StakingForm/StakingForm.tsx new file mode 100644 index 0000000000..5d702154f2 --- /dev/null +++ b/src/pages/Staking/components/StakingForm/StakingForm.tsx @@ -0,0 +1,281 @@ +import { CurrencyExt, newMonetaryAmount } from '@interlay/interbtc-api'; +import { MonetaryAmount } from '@interlay/monetary-js'; +import { mergeProps } from '@react-aria/utils'; +import { ChangeEvent, useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { convertMonetaryAmountToValueInUSD, newSafeMonetaryAmount } from '@/common/utils/utils'; +import { Alert, Card, CardProps, Divider, Flex, H1, TokenInput } from '@/component-library'; +import { AuthCTA, TransactionFeeDetails } from '@/components'; +import { GOVERNANCE_TOKEN, STAKE_LOCK_TIME } from '@/config/relay-chains'; +import { AccountStakingData } from '@/hooks/api/escrow/use-get-account-staking-data'; +import { useGetStakingDetailsData } from '@/hooks/api/escrow/use-get-staking-details-data'; +import { NetworkStakingData } from '@/hooks/api/escrow/uset-get-network-staking-data'; +import { useGetBalances } from '@/hooks/api/tokens/use-get-balances'; +import { useGetPrices } from '@/hooks/api/use-get-prices'; +import { Transaction, useTransaction } from '@/hooks/transaction'; +import { isTransactionFormDisabled } from '@/hooks/transaction/utils/form'; +import { useWallet } from '@/hooks/use-wallet'; +import { + STAKING_AMOUNT_FIELD, + STAKING_FEE_TOKEN_FIELD, + STAKING_LOCKED_WEEKS_AMOUNT_FIELD, + StakingFormData, + stakingSchema, + useForm +} from '@/lib/form'; +import { pickSmallerAmount } from '@/utils/helpers/currencies'; +import { getTokenInputProps } from '@/utils/helpers/input'; +import { getTokenPrice } from '@/utils/helpers/prices'; +import { convertBlockNumbersToWeeks, convertWeeksToBlockNumbers } from '@/utils/helpers/staking'; + +import { StakingLockTimeInput } from './StakingLockTimeInput'; +import { StakingNetworkDetails } from './StakingNetworkDetails'; +import { StakingTransactionDetails } from './StakingTransactionDetails'; + +const MIN_AMOUNT = newMonetaryAmount(1, GOVERNANCE_TOKEN); + +type Props = { + accountData?: AccountStakingData | null; + networkData: NetworkStakingData; + onStaking: () => void; +}; + +type InheritAttrs = Omit; + +type StakingFormProps = Props & InheritAttrs; + +const StakingForm = ({ accountData, networkData, onStaking, ...props }: StakingFormProps): JSX.Element => { + const prices = useGetPrices(); + const { t } = useTranslation(); + const { data: balances, getAvailableBalance } = useGetBalances(); + const { account } = useWallet(); + + const { data: detailsData, mutate: mutateDetails } = useGetStakingDetailsData(); + + const transaction = useTransaction({ + onSuccess: () => { + form.resetForm(); + onStaking(); + } + }); + + const hasStake = !!accountData; + + const governanceBalance = getAvailableBalance(GOVERNANCE_TOKEN.ticker) || newMonetaryAmount(0, GOVERNANCE_TOKEN); + const inputBalance = accountData?.limit + ? governanceBalance && pickSmallerAmount(governanceBalance, accountData.limit) + : governanceBalance; + + const getTransactionArgs = useCallback( + async (values: StakingFormData) => { + const amount = newMonetaryAmount(values[STAKING_AMOUNT_FIELD] || 0, GOVERNANCE_TOKEN, true); + const weeksLocked = Number(values[STAKING_LOCKED_WEEKS_AMOUNT_FIELD]); + + const hasAmount = !amount.isZero(); + const hasWeeksLocked = weeksLocked > 0; + + if (accountData) { + const blockNumber = hasWeeksLocked ? convertWeeksToBlockNumbers(weeksLocked) : 0; + + const unlockHeight = accountData.unlock.block + blockNumber; + + if (hasAmount && hasWeeksLocked) { + return { transactionType: Transaction.ESCROW_INCREASE_LOOKED_TIME_AND_AMOUNT as const, amount, unlockHeight }; + } else if (hasAmount && !hasWeeksLocked) { + return { transactionType: Transaction.ESCROW_INCREASE_LOCKED_AMOUNT as const, amount }; + } else { + return { transactionType: Transaction.ESCROW_INCREASE_LOCKED_TIME as const, unlockHeight }; + } + } else { + const currentBlockNumber = await window.bridge.system.getCurrentBlockNumber(); + const unlockHeight = currentBlockNumber + convertWeeksToBlockNumbers(weeksLocked); + + return { transactionType: Transaction.ESCROW_CREATE_LOCK as const, amount, unlockHeight }; + } + }, + [accountData] + ); + + const handleSubmit = async (values: StakingFormData) => { + const data = await getTransactionArgs(values); + + if (!data) return; + + let monetaryAmount = data.amount; + + if (monetaryAmount && transaction.fee.isEqualFeeCurrency(monetaryAmount.currency)) { + monetaryAmount = transaction.calculateAmountWithFeeDeducted(monetaryAmount); + } + + switch (data.transactionType) { + case Transaction.ESCROW_CREATE_LOCK: + return transaction.execute( + data.transactionType, + monetaryAmount as MonetaryAmount, + data.unlockHeight + ); + case Transaction.ESCROW_INCREASE_LOOKED_TIME_AND_AMOUNT: + return transaction.execute( + data.transactionType, + monetaryAmount as MonetaryAmount, + data.unlockHeight + ); + case Transaction.ESCROW_INCREASE_LOCKED_AMOUNT: + return transaction.execute(data.transactionType, monetaryAmount as MonetaryAmount); + case Transaction.ESCROW_INCREASE_LOCKED_TIME: + return transaction.execute(data.transactionType, data.unlockHeight); + } + }; + + const lockedWeeksLimit = useMemo(() => { + if (!accountData) { + return { min: STAKE_LOCK_TIME.MIN, max: STAKE_LOCK_TIME.MAX }; + } + + const remainingWeeks = convertBlockNumbersToWeeks(accountData.unlock.remainingBlocks); + + return { min: 0, max: Math.floor(STAKE_LOCK_TIME.MAX - remainingWeeks) }; + }, [accountData]); + + const form = useForm({ + initialValues: { + [STAKING_AMOUNT_FIELD]: '', + [STAKING_LOCKED_WEEKS_AMOUNT_FIELD]: '', + [STAKING_FEE_TOKEN_FIELD]: transaction.fee.defaultCurrency.ticker + }, + validationSchema: stakingSchema( + { + [STAKING_AMOUNT_FIELD]: { + maxAmount: inputBalance, + minAmount: MIN_AMOUNT + }, + [STAKING_LOCKED_WEEKS_AMOUNT_FIELD]: lockedWeeksLimit + }, + hasStake + ), + onSubmit: handleSubmit, + onComplete: async (values) => { + if (accountData?.unlock.isAvailable) return; + + const data = await getTransactionArgs(values); + + if (!data) return; + + switch (data.transactionType) { + case Transaction.ESCROW_CREATE_LOCK: + return transaction.fee.estimate(data.transactionType, data.amount, data.unlockHeight); + case Transaction.ESCROW_INCREASE_LOOKED_TIME_AND_AMOUNT: + return transaction.fee.estimate(data.transactionType, data.amount, data.unlockHeight); + case Transaction.ESCROW_INCREASE_LOCKED_AMOUNT: + return transaction.fee.estimate(data.transactionType, data.amount); + case Transaction.ESCROW_INCREASE_LOCKED_TIME: + return transaction.fee.estimate(data.transactionType, data.unlockHeight); + } + } + }); + + useEffect(() => { + form.validateForm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [balances, accountData]); + + useEffect(() => { + handleDetails(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountData]); + + const handleDetails = (amountProp?: string, weeksLockedProp?: number) => { + if (!account || accountData?.unlock.isAvailable) return; + + const amount = amountProp || form.values[STAKING_AMOUNT_FIELD] || 0; + + const monetaryAmount = newSafeMonetaryAmount(amount, GOVERNANCE_TOKEN, true); + + const weeksLocked = + weeksLockedProp === undefined ? Number(form.values[STAKING_LOCKED_WEEKS_AMOUNT_FIELD]) : weeksLockedProp; + + mutateDetails({ amount: monetaryAmount, weeksLocked }); + }; + + const handleListSelectionChange = (value: number) => { + form.setFieldValue(STAKING_LOCKED_WEEKS_AMOUNT_FIELD, value, true); + + handleDetails(undefined, value); + }; + + const handleChangeAmount = (e: ChangeEvent) => { + const amount = e.target.value; + + handleDetails(amount); + }; + + const handleChangeLockTime = (e: ChangeEvent) => { + const weeksLocked = e.target.value; + + handleDetails(undefined, weeksLocked ? Number(weeksLocked) : 0); + }; + + const monetaryAmount = newSafeMonetaryAmount(form.values[STAKING_AMOUNT_FIELD] || 0, GOVERNANCE_TOKEN, true); + const amountUSD = monetaryAmount + ? convertMonetaryAmountToValueInUSD(monetaryAmount, getTokenPrice(prices, monetaryAmount.currency.ticker)?.usd) || 0 + : 0; + + const isBtnDisabled = accountData?.unlock.isAvailable || isTransactionFormDisabled(form, transaction.fee); + + const shouldDisplayWithdrawAlert = accountData?.unlock.isAvailable && form.dirty; + + return ( + +

+ {t('staking_page.stake_ticker', { ticker: GOVERNANCE_TOKEN.ticker })} +

+ + +
+ + + + + {shouldDisplayWithdrawAlert && ( + + {t('staking_pages.your_already_staked_ticker_needs_to_be_withdrawn', { + ticker: GOVERNANCE_TOKEN.ticker + })} + + )} + + + + + + {t('stake')} + + +
+
+
+ ); +}; + +export { StakingForm }; +export type { StakingFormProps }; diff --git a/src/pages/Staking/components/StakingForm/StakingLockTimeInput.tsx b/src/pages/Staking/components/StakingForm/StakingLockTimeInput.tsx new file mode 100644 index 0000000000..39153c3daa --- /dev/null +++ b/src/pages/Staking/components/StakingForm/StakingLockTimeInput.tsx @@ -0,0 +1,109 @@ +import { mergeProps } from '@react-aria/utils'; +import { Key, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Flex, + FlexProps, + InputProps, + ListItem, + ListProps, + NumberInput, + theme, + useMediaQuery +} from '@/component-library'; + +import { StyledList } from './StakingForm.style'; + +type Props = { + isExtending: boolean; + min: number; + max: number; + inputProps: InputProps; + onListSelectionChange?: (value: number) => void; +}; + +type InheritAttrs = Omit; + +type StakingLockTimeInputProps = Props & InheritAttrs; + +const StakingLockTimeInput = ({ + isExtending, + min, + max, + onListSelectionChange, + inputProps, + ...props +}: StakingLockTimeInputProps): JSX.Element => { + const { t } = useTranslation(); + + const [listLockTime, setListLockTime] = useState(inputProps.value?.toString() as Key); + + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const handleSelectionChange: ListProps['onSelectionChange'] = (key) => { + const [selectedKey] = [...key]; + + const value = Number(selectedKey); + + onListSelectionChange?.(value); + setListLockTime(selectedKey); + }; + + const items = useMemo( + () => [ + { label: t('staking_page.time.one_week'), value: '1' }, + { label: t('staking_page.time.one_month'), value: '4' }, + { label: t('staking_page.time.three_month'), value: '13' }, + { label: t('staking_page.time.six_month'), value: '26' }, + { label: t('max'), value: max.toString() } + ], + [max, t] + ); + + const isDisabled = max <= 0; + + const listKeys = useMemo(() => items.map((item) => item.value), [items]); + + const disabledKeys = isDisabled ? listKeys : listKeys.filter((key) => (key === 'max' ? max : Number(key)) > max); + + const label = isExtending + ? t('staking_page.extended_lock_time_in_weeks', { value: max }) + : t('staking_page.lock_time_in_weeks', { value: max }); + + return ( + + setListLockTime(undefined), + ...(!isMobile && { labelPosition: 'side', justifyContent: 'space-between', maxWidth: 'spacing12' }) + })} + /> + + {items.map((item) => ( + + {item.label} + + ))} + + + ); +}; + +export { StakingLockTimeInput }; +export type { StakingLockTimeInputProps }; diff --git a/src/pages/Staking/components/StakingForm/StakingNetworkDetails.tsx b/src/pages/Staking/components/StakingForm/StakingNetworkDetails.tsx new file mode 100644 index 0000000000..41e01c0b93 --- /dev/null +++ b/src/pages/Staking/components/StakingForm/StakingNetworkDetails.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next'; + +import { + TransactionDetails, + TransactionDetailsDd, + TransactionDetailsDt, + TransactionDetailsGroup, + TransactionDetailsProps +} from '@/components'; +import { GOVERNANCE_TOKEN, VOTE_GOVERNANCE_TOKEN } from '@/config/relay-chains'; +import { NetworkStakingData } from '@/hooks/api/escrow/uset-get-network-staking-data'; + +type Props = { + data: NetworkStakingData; +}; + +type InheritAttrs = Omit; + +type StakingNetworkDetailsProps = Props & InheritAttrs; + +const StakingNetworkDetails = ({ data, ...props }: StakingNetworkDetailsProps): JSX.Element => { + const { t } = useTranslation(); + + return ( + + + + {t('staking_page.total_staked_ticker_in_the_network', { ticker: GOVERNANCE_TOKEN.ticker })} + + + {data.totalStakedBalance.toHuman()} {GOVERNANCE_TOKEN.ticker} + + + + + {t('staking_page.total_ticker_in_the_network', { ticker: GOVERNANCE_TOKEN.ticker })} + + + {data.totalVotingSupply.toHuman()} {VOTE_GOVERNANCE_TOKEN.ticker} + + + + ); +}; + +export { StakingNetworkDetails }; +export type { StakingNetworkDetailsProps }; diff --git a/src/pages/Staking/components/StakingForm/StakingTransactionDetails.tsx b/src/pages/Staking/components/StakingForm/StakingTransactionDetails.tsx new file mode 100644 index 0000000000..0be0a834fa --- /dev/null +++ b/src/pages/Staking/components/StakingForm/StakingTransactionDetails.tsx @@ -0,0 +1,73 @@ +import { format } from 'date-fns'; +import { useTranslation } from 'react-i18next'; + +import { formatPercentage } from '@/common/utils/utils'; +import { + TransactionDetails, + TransactionDetailsDd, + TransactionDetailsDt, + TransactionDetailsGroup, + TransactionDetailsProps +} from '@/components'; +import { GOVERNANCE_TOKEN, VOTE_GOVERNANCE_TOKEN } from '@/config/relay-chains'; +import { AccountStakingDetailsData } from '@/hooks/api/escrow/use-get-staking-details-data'; +import { YEAR_MONTH_DAY_PATTERN } from '@/utils/constants/date-time'; + +type Props = { + hasStake: boolean; + data?: AccountStakingDetailsData; +}; + +type InheritAttrs = Omit; + +type StakingTransactionDetailsProps = Props & InheritAttrs; + +const StakingTransactionDetails = ({ hasStake, data, ...props }: StakingTransactionDetailsProps): JSX.Element => { + const { t } = useTranslation(); + + const { totalStaked, votingBalanceGained, apy, governanceBalanceReward, date } = data || {}; + + const unlockDateTerm = hasStake ? t('staking_page.new_unlock_date') : t('staking_page.unlock_date'); + + const unlockDateLabel = date ? format(date, YEAR_MONTH_DAY_PATTERN) : '-'; + + return ( + + + {unlockDateTerm} + {unlockDateLabel} + + + + {t('staking_page.new_ticker_gained', { ticker: VOTE_GOVERNANCE_TOKEN.ticker })} + + + {votingBalanceGained?.toHuman() || 0} {VOTE_GOVERNANCE_TOKEN.ticker} + + + {hasStake && ( + + {t('staking_page.new_total_stake')} + + {totalStaked?.toHuman() || 0} {VOTE_GOVERNANCE_TOKEN.ticker} + + + )} + + {t('staking_page.estimated_apr')} + {formatPercentage(apy?.toNumber() || 0)} + + + + {t('staking_page.projected_ticker_rewards', { ticker: GOVERNANCE_TOKEN.ticker })} + + + {governanceBalanceReward?.toHuman() || 0} {GOVERNANCE_TOKEN.ticker} + + + + ); +}; + +export { StakingTransactionDetails }; +export type { StakingTransactionDetailsProps }; diff --git a/src/pages/Staking/components/StakingForm/index.tsx b/src/pages/Staking/components/StakingForm/index.tsx new file mode 100644 index 0000000000..9559cf9a0a --- /dev/null +++ b/src/pages/Staking/components/StakingForm/index.tsx @@ -0,0 +1,2 @@ +export type { StakingFormProps } from './StakingForm'; +export { StakingForm } from './StakingForm'; diff --git a/src/pages/Staking/components/StakingWithdrawCard/StakingWithdrawCard.tsx b/src/pages/Staking/components/StakingWithdrawCard/StakingWithdrawCard.tsx new file mode 100644 index 0000000000..c6b8766717 --- /dev/null +++ b/src/pages/Staking/components/StakingWithdrawCard/StakingWithdrawCard.tsx @@ -0,0 +1,65 @@ +import { format } from 'date-fns'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Card, CardProps, P } from '@/component-library'; +import { AuthCTA, ClaimModal } from '@/components'; +import { GOVERNANCE_TOKEN } from '@/config/relay-chains'; +import { AccountStakingData } from '@/hooks/api/escrow/use-get-account-staking-data'; +import { Transaction, useTransaction } from '@/hooks/transaction'; +import { YEAR_MONTH_DAY_PATTERN } from '@/utils/constants/date-time'; + +type Props = { + data: AccountStakingData; + onWithdraw: () => void; +}; + +type InheritAttrs = CardProps & Props; + +type StakingWithdrawCardProps = Props & InheritAttrs; + +const StakingWithdrawCard = ({ data, onWithdraw, ...props }: StakingWithdrawCardProps): JSX.Element => { + const { t } = useTranslation(); + const [isOpen, setOpen] = useState(false); + + const transaction = useTransaction(Transaction.ESCROW_WITHDRAW, { + onSuccess: () => { + onWithdraw(); + setOpen(false); + } + }); + + const handleSubmit = () => transaction.execute(); + + const handleOpen = () => transaction.fee.estimate(); + + const handlePress = () => setOpen(true); + + return ( + <> + +

+ {t('staking_page.withdraw_staked_ticker_on_date', { + ticker: GOVERNANCE_TOKEN.ticker + })}{' '} + {format(data.unlock.date, YEAR_MONTH_DAY_PATTERN)} +

+ + {t('withdraw')} + +
+ setOpen(false)} + title={t('staking_page.withdraw_staked_ticker', { ticker: GOVERNANCE_TOKEN.ticker })} + submitLabel={t('withdraw')} + transaction={transaction} + onSubmit={handleSubmit} + onOpen={handleOpen} + /> + + ); +}; + +export { StakingWithdrawCard }; +export type { StakingWithdrawCardProps }; diff --git a/src/pages/Staking/components/StakingWithdrawCard/index.tsx b/src/pages/Staking/components/StakingWithdrawCard/index.tsx new file mode 100644 index 0000000000..69ea4c54f1 --- /dev/null +++ b/src/pages/Staking/components/StakingWithdrawCard/index.tsx @@ -0,0 +1,2 @@ +export type { StakingWithdrawCardProps } from './StakingWithdrawCard'; +export { StakingWithdrawCard } from './StakingWithdrawCard'; diff --git a/src/pages/Staking/components/index.tsx b/src/pages/Staking/components/index.tsx new file mode 100644 index 0000000000..38c01ceaaa --- /dev/null +++ b/src/pages/Staking/components/index.tsx @@ -0,0 +1,2 @@ +export { StakingAccountDetails } from './StakingAccountDetails'; +export { StakingForm } from './StakingForm'; diff --git a/src/pages/Staking/index.tsx b/src/pages/Staking/index.tsx index 9f5f84e09f..bdb5bd5cbe 100644 --- a/src/pages/Staking/index.tsx +++ b/src/pages/Staking/index.tsx @@ -1,847 +1,3 @@ -import { newMonetaryAmount } from '@interlay/interbtc-api'; -import Big from 'big.js'; -import clsx from 'clsx'; -import { add, format } from 'date-fns'; -import * as React from 'react'; -import { useErrorHandler, withErrorBoundary } from 'react-error-boundary'; -import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { useQuery, useQueryClient } from 'react-query'; -import { useSelector } from 'react-redux'; +import Swap from './Staking'; -import { StoreType } from '@/common/types/util.types'; -import { - displayMonetaryAmount, - displayMonetaryAmountInUSDFormat, - formatNumber, - formatPercentage -} from '@/common/utils/utils'; -import { AuthCTA, MainContainer } from '@/components'; -import { BLOCK_TIME } from '@/config/parachain'; -import { - GOVERNANCE_TOKEN, - GOVERNANCE_TOKEN_SYMBOL, - GovernanceTokenMonetaryAmount, - STAKE_LOCK_TIME, - VOTE_GOVERNANCE_TOKEN, - VOTE_GOVERNANCE_TOKEN_SYMBOL, - VoteGovernanceTokenMonetaryAmount -} from '@/config/relay-chains'; -import { useGetBalances } from '@/hooks/api/tokens/use-get-balances'; -import { useGetPrices } from '@/hooks/api/use-get-prices'; -import { Transaction, useTransaction } from '@/hooks/transaction'; -import { useSignMessage } from '@/hooks/use-sign-message'; -import AvailableBalanceUI from '@/legacy-components/AvailableBalanceUI'; -import ErrorFallback from '@/legacy-components/ErrorFallback'; -import Panel from '@/legacy-components/Panel'; -import TitleWithUnderline from '@/legacy-components/TitleWithUnderline'; -import TokenField from '@/legacy-components/TokenField'; -import InformationTooltip from '@/legacy-components/tooltips/InformationTooltip'; -import { useSubstrateSecureState } from '@/lib/substrate'; -import genericFetcher, { GENERIC_FETCHER } from '@/services/fetchers/generic-fetcher'; -import { - STAKING_TRANSACTION_FEE_RESERVE_FETCHER, - stakingTransactionFeeReserveFetcher -} from '@/services/fetchers/staking-transaction-fee-reserve-fetcher'; -import { ZERO_GOVERNANCE_TOKEN_AMOUNT, ZERO_VOTE_GOVERNANCE_TOKEN_AMOUNT } from '@/utils/constants/currency'; -import { YEAR_MONTH_DAY_PATTERN } from '@/utils/constants/date-time'; -import { getTokenPrice } from '@/utils/helpers/prices'; - -import BalancesUI from './BalancesUI'; -import ClaimRewardsButton from './ClaimRewardsButton'; -import InformationUI from './InformationUI'; -import LockTimeField from './LockTimeField'; -import TotalsUI from './TotalsUI'; -import WithdrawButton from './WithdrawButton'; - -const SHARED_CLASSES = clsx('mx-auto', 'md:max-w-2xl'); - -const ONE_WEEK_SECONDS = 7 * 24 * 3600; - -const convertWeeksToBlockNumbers = (weeks: number) => { - return (weeks * ONE_WEEK_SECONDS) / BLOCK_TIME; -}; - -const convertBlockNumbersToWeeks = (blockNumbers: number) => { - return (blockNumbers * BLOCK_TIME) / ONE_WEEK_SECONDS; -}; - -// When to increase lock amount and extend lock time -const checkIncreaseLockAmountAndExtendLockTime = (lockTime: number, lockAmount: GovernanceTokenMonetaryAmount) => { - return lockTime > 0 && lockAmount.gt(ZERO_GOVERNANCE_TOKEN_AMOUNT); -}; -// When to only increase lock amount -const checkOnlyIncreaseLockAmount = (lockTime: number, lockAmount: GovernanceTokenMonetaryAmount) => { - return lockTime === 0 && lockAmount.gt(ZERO_GOVERNANCE_TOKEN_AMOUNT); -}; -// When to only extend lock time -const checkOnlyExtendLockTime = (lockTime: number, lockAmount: GovernanceTokenMonetaryAmount) => { - return lockTime > 0 && lockAmount.eq(ZERO_GOVERNANCE_TOKEN_AMOUNT); -}; - -const LOCKING_AMOUNT = 'locking-amount'; -const LOCK_TIME = 'lock-time'; - -type StakingFormData = { - [LOCKING_AMOUNT]: string; - [LOCK_TIME]: string; -}; - -interface EstimatedRewardAmountAndAPY { - amount: GovernanceTokenMonetaryAmount; - apy: Big; -} - -interface StakedAmountAndEndBlock { - amount: GovernanceTokenMonetaryAmount; - endBlock: number; -} - -const Staking = (): JSX.Element => { - const [blockLockTimeExtension, setBlockLockTimeExtension] = React.useState(0); - - const { t } = useTranslation(); - const prices = useGetPrices(); - - const { selectedAccount } = useSubstrateSecureState(); - - const selectedAccountAddress = selectedAccount?.address ?? ''; - - const { bridgeLoaded } = useSelector((state: StoreType) => state.general); - - const { data: balances, isLoading: isBalancesLoading } = useGetBalances(); - const governanceTokenBalance = balances?.[GOVERNANCE_TOKEN.ticker]; - - const queryClient = useQueryClient(); - - const { hasSignature } = useSignMessage(); - - const { - register, - handleSubmit, - watch, - reset, - formState: { errors, isValid, isValidating }, - trigger, - setValue - } = useForm({ - mode: 'onChange', // 'onBlur' - defaultValues: { - [LOCKING_AMOUNT]: '0', - [LOCK_TIME]: '0' - } - }); - const lockingAmount = watch(LOCKING_AMOUNT) || '0'; - const lockTime = watch(LOCK_TIME) || '0'; - - const { - isIdle: currentBlockNumberIdle, - isLoading: currentBlockNumberLoading, - data: currentBlockNumber, - error: currentBlockNumberError - } = useQuery([GENERIC_FETCHER, 'system', 'getCurrentBlockNumber'], genericFetcher(), { - enabled: !!bridgeLoaded - }); - useErrorHandler(currentBlockNumberError); - - const { - isIdle: voteGovernanceTokenBalanceIdle, - isLoading: voteGovernanceTokenBalanceLoading, - data: voteGovernanceTokenBalance, - error: voteGovernanceTokenBalanceError - } = useQuery( - [GENERIC_FETCHER, 'escrow', 'votingBalance', selectedAccountAddress], - genericFetcher(), - { - enabled: !!bridgeLoaded - } - ); - useErrorHandler(voteGovernanceTokenBalanceError); - - // My currently claimable rewards - const { - isIdle: claimableRewardAmountIdle, - isLoading: claimableRewardAmountLoading, - data: claimableRewardAmount, - error: claimableRewardAmountError - } = useQuery( - [GENERIC_FETCHER, 'escrow', 'getRewards', selectedAccountAddress], - genericFetcher(), - { - enabled: !!bridgeLoaded - } - ); - useErrorHandler(claimableRewardAmountError); - - // Projected governance token rewards - const { - isIdle: projectedRewardAmountAndAPYIdle, - isLoading: projectedRewardAmountAndAPYLoading, - data: projectedRewardAmountAndAPY, - error: rewardAmountAndAPYError - } = useQuery( - [GENERIC_FETCHER, 'escrow', 'getRewardEstimate', selectedAccountAddress], - genericFetcher(), - { - enabled: !!bridgeLoaded - } - ); - useErrorHandler(rewardAmountAndAPYError); - - // Estimated governance token Rewards & APY - const monetaryLockingAmount = newMonetaryAmount(lockingAmount, GOVERNANCE_TOKEN, true); - const { - isLoading: estimatedRewardAmountAndAPYLoading, - data: estimatedRewardAmountAndAPY, - error: estimatedRewardAmountAndAPYError, - refetch: estimatedRewardAmountAndAPYRefetch - } = useQuery( - [ - GENERIC_FETCHER, - 'escrow', - 'getRewardEstimate', - selectedAccountAddress, - monetaryLockingAmount, - blockLockTimeExtension - ], - genericFetcher(), - { - enabled: false, - retry: false - } - ); - useErrorHandler(estimatedRewardAmountAndAPYError); - - const { - isIdle: stakedAmountAndEndBlockIdle, - isLoading: stakedAmountAndEndBlockLoading, - data: stakedAmountAndEndBlock, - error: stakedAmountAndEndBlockError - } = useQuery( - [GENERIC_FETCHER, 'escrow', 'getStakedBalance', selectedAccountAddress], - genericFetcher(), - { - enabled: !!bridgeLoaded - } - ); - useErrorHandler(stakedAmountAndEndBlockError); - - const { - isIdle: transactionFeeReserveIdle, - isLoading: transactionFeeReserveLoading, - data: transactionFeeReserve, - error: transactionFeeReserveError - } = useQuery( - [STAKING_TRANSACTION_FEE_RESERVE_FETCHER, selectedAccountAddress], - stakingTransactionFeeReserveFetcher(selectedAccountAddress), - { - enabled: bridgeLoaded && !!selectedAccount - } - ); - useErrorHandler(transactionFeeReserveError); - - const initialStakeTransaction = useTransaction(Transaction.ESCROW_CREATE_LOCK, { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [GENERIC_FETCHER, 'escrow'] }); - reset({ - [LOCKING_AMOUNT]: '0.0', - [LOCK_TIME]: '0' - }); - } - }); - - const existingStakeTransaction = useTransaction({ - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [GENERIC_FETCHER, 'escrow'] }); - reset({ - [LOCKING_AMOUNT]: '0.0', - [LOCK_TIME]: '0' - }); - } - }); - - React.useEffect(() => { - if (isValidating || !isValid || !estimatedRewardAmountAndAPYRefetch) return; - - estimatedRewardAmountAndAPYRefetch(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isValid, isValidating, lockTime, lockingAmount, estimatedRewardAmountAndAPYRefetch]); - - React.useEffect(() => { - if (!lockTime) return; - if (!currentBlockNumber) return; - - const lockTimeValue = Number(lockTime); - const extensionTime = - (stakedAmountAndEndBlock?.endBlock || currentBlockNumber) + convertWeeksToBlockNumbers(lockTimeValue); - - setBlockLockTimeExtension(extensionTime); - }, [currentBlockNumber, lockTime, stakedAmountAndEndBlock]); - - React.useEffect(() => { - queryClient.invalidateQueries({ queryKey: [GENERIC_FETCHER, 'escrow'] }); - reset({ - [LOCKING_AMOUNT]: '', - [LOCK_TIME]: '' - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedAccount, reset]); - - const votingBalanceGreaterThanZero = voteGovernanceTokenBalance?.gt(ZERO_VOTE_GOVERNANCE_TOKEN_AMOUNT); - - const extendLockTimeSet = votingBalanceGreaterThanZero && parseInt(lockTime) > 0; - const increaseLockingAmountSet = - votingBalanceGreaterThanZero && monetaryLockingAmount.gt(ZERO_GOVERNANCE_TOKEN_AMOUNT); - - React.useEffect(() => { - if (extendLockTimeSet) { - trigger(LOCKING_AMOUNT); - } - }, [lockTime, extendLockTimeSet, trigger]); - - React.useEffect(() => { - if (increaseLockingAmountSet) { - trigger(LOCK_TIME); - } - }, [lockingAmount, increaseLockingAmountSet, trigger]); - - const getStakedAmount = () => { - if (stakedAmountAndEndBlockIdle || stakedAmountAndEndBlockLoading) { - return undefined; - } - if (stakedAmountAndEndBlock === undefined) { - throw new Error('Something went wrong!'); - } - - return stakedAmountAndEndBlock.amount; - }; - const stakedAmount = getStakedAmount(); - - const availableBalance = React.useMemo(() => { - if ( - isBalancesLoading || - stakedAmountAndEndBlockIdle || - stakedAmountAndEndBlockLoading || - transactionFeeReserveIdle || - transactionFeeReserveLoading - ) - return; - if (stakedAmount === undefined) { - throw new Error('Staked amount value returned undefined!'); - } - if (transactionFeeReserve === undefined) { - throw new Error('Transaction fee reserve value returned undefined!'); - } - if (governanceTokenBalance === undefined) { - throw new Error('Governance token balance value returned undefined!'); - } - - const calculatedBalance = governanceTokenBalance.free.sub(stakedAmount).sub(transactionFeeReserve); - - return calculatedBalance.toBig().gte(0) ? calculatedBalance : newMonetaryAmount(0, GOVERNANCE_TOKEN); - }, [ - isBalancesLoading, - governanceTokenBalance, - stakedAmountAndEndBlockIdle, - stakedAmountAndEndBlockLoading, - stakedAmount, - transactionFeeReserveIdle, - transactionFeeReserveLoading, - transactionFeeReserve - ]); - - const onSubmit = (data: StakingFormData) => { - if (!bridgeLoaded) return; - if (currentBlockNumber === undefined) { - throw new Error('Something went wrong!'); - } - - const lockingAmountWithFallback = data[LOCKING_AMOUNT] || '0'; - const lockTimeWithFallback = data[LOCK_TIME] || '0'; // Weeks - - const monetaryAmount = newMonetaryAmount(lockingAmountWithFallback, GOVERNANCE_TOKEN, true); - const numberTime = parseInt(lockTimeWithFallback); - - if (votingBalanceGreaterThanZero) { - if (stakedAmountAndEndBlock === undefined) { - throw new Error('Something went wrong!'); - } - - if (checkIncreaseLockAmountAndExtendLockTime(numberTime, monetaryAmount)) { - const unlockHeight = stakedAmountAndEndBlock.endBlock + convertWeeksToBlockNumbers(numberTime); - - existingStakeTransaction.execute( - Transaction.ESCROW_INCREASE_LOOKED_TIME_AND_AMOUNT, - monetaryAmount.toString(true), - unlockHeight - ); - } else if (checkOnlyIncreaseLockAmount(numberTime, monetaryAmount)) { - existingStakeTransaction.execute(Transaction.ESCROW_INCREASE_LOCKED_AMOUNT, monetaryAmount); - } else if (checkOnlyExtendLockTime(numberTime, monetaryAmount)) { - const unlockHeight = stakedAmountAndEndBlock.endBlock + convertWeeksToBlockNumbers(numberTime); - - existingStakeTransaction.execute(Transaction.ESCROW_INCREASE_LOCKED_TIME, unlockHeight); - } else { - throw new Error('Something went wrong!'); - } - } else { - const unlockHeight = currentBlockNumber + convertWeeksToBlockNumbers(numberTime); - initialStakeTransaction.execute(monetaryAmount, unlockHeight); - } - }; - - const validateLockingAmount = (value: string): string | undefined => { - const valueWithFallback = value || '0'; - const monetaryLockingAmount = newMonetaryAmount(valueWithFallback, GOVERNANCE_TOKEN, true); - - if (!extendLockTimeSet && monetaryLockingAmount.lte(ZERO_GOVERNANCE_TOKEN_AMOUNT)) { - return 'Locking amount must be greater than zero!'; - } - - if (availableBalance === undefined) { - throw new Error('Something went wrong!'); - } - if (monetaryLockingAmount.gt(availableBalance)) { - return 'Locking amount must not be greater than available balance!'; - } - - const planckLockingAmount = monetaryLockingAmount.toBig(0); - const lockBlocks = convertWeeksToBlockNumbers(parseInt(lockTime)); - // This is related to the on-chain implementation where currency values are integers. - // So less tokens than the period would likely round to 0. - // So on the UI, as long as you require more planck to be locked than the number of blocks the user locks for, - // it should be good. - if (!extendLockTimeSet && planckLockingAmount.lte(Big(lockBlocks))) { - return 'Planck to be locked must be greater than the number of blocks you lock for!'; - } - - return undefined; - }; - - const validateLockTime = (value: string): string | undefined => { - const valueWithFallback = value || '0'; - const numericValue = parseInt(valueWithFallback); - - if (votingBalanceGreaterThanZero && numericValue === 0 && monetaryLockingAmount.gt(ZERO_GOVERNANCE_TOKEN_AMOUNT)) { - return undefined; - } - - if (availableLockTime === undefined) { - throw new Error('Something went wrong!'); - } - if (numericValue < STAKE_LOCK_TIME.MIN || numericValue > availableLockTime) { - return `Please enter a number between ${STAKE_LOCK_TIME.MIN}-${availableLockTime}.`; - } - - return undefined; - }; - - const renderVoteStakedAmountLabel = () => { - if (voteGovernanceTokenBalanceIdle || voteGovernanceTokenBalanceLoading) { - return '-'; - } - if (voteGovernanceTokenBalance === undefined) { - throw new Error('Something went wrong!'); - } - - return displayMonetaryAmount(voteGovernanceTokenBalance); - }; - - const renderProjectedRewardAmountLabel = () => { - if (projectedRewardAmountAndAPYIdle || projectedRewardAmountAndAPYLoading) { - return '-'; - } - if (projectedRewardAmountAndAPY === undefined) { - throw new Error('Something went wrong!'); - } - - return displayMonetaryAmount(projectedRewardAmountAndAPY.amount); - }; - - const renderStakedAmountLabel = () => { - return stakedAmount === undefined ? '-' : displayMonetaryAmount(stakedAmount); - }; - - const hasStakedAmount = stakedAmount?.gt(ZERO_GOVERNANCE_TOKEN_AMOUNT); - - const getRemainingBlockNumbersToUnstake = () => { - if ( - stakedAmountAndEndBlockIdle || - stakedAmountAndEndBlockLoading || - currentBlockNumberIdle || - currentBlockNumberLoading - ) { - return undefined; - } - if (stakedAmountAndEndBlock === undefined) { - throw new Error('Something went wrong!'); - } - if (currentBlockNumber === undefined) { - throw new Error('Something went wrong!'); - } - - return hasStakedAmount - ? stakedAmountAndEndBlock.endBlock - currentBlockNumber // If the user has staked - : null; // If the user has not staked - }; - const remainingBlockNumbersToUnstake = getRemainingBlockNumbersToUnstake(); - - const getAvailableLockTime = () => { - if (remainingBlockNumbersToUnstake === undefined) { - return undefined; - } - - // If the user has staked - if (hasStakedAmount) { - if (remainingBlockNumbersToUnstake === null) { - throw new Error('Something went wrong!'); - } - const remainingWeeksToUnstake = convertBlockNumbersToWeeks(remainingBlockNumbersToUnstake); - - return Math.floor(STAKE_LOCK_TIME.MAX - remainingWeeksToUnstake); - // If the user has not staked - } else { - return STAKE_LOCK_TIME.MAX; - } - }; - const availableLockTime = getAvailableLockTime(); - - const availableMonetaryBalance = availableBalance?.toHuman(5); - - const renderUnlockDateLabel = () => { - if (errors[LOCK_TIME]) { - return '-'; - } - - const unlockDate = add(new Date(), { - weeks: parseInt(lockTime) - }); - - return format(unlockDate, YEAR_MONTH_DAY_PATTERN); - }; - - const renderNewUnlockDateLabel = () => { - if (remainingBlockNumbersToUnstake === undefined) { - return '-'; - } - if (errors[LOCK_TIME]) { - return '-'; - } - - let remainingLockSeconds; - if (hasStakedAmount) { - if (remainingBlockNumbersToUnstake === null) { - throw new Error('Something went wrong!'); - } - - remainingLockSeconds = remainingBlockNumbersToUnstake * BLOCK_TIME; - } else { - remainingLockSeconds = 0; - } - const unlockDate = add(new Date(), { - weeks: parseInt(lockTime), - seconds: remainingLockSeconds - }); - - return format(unlockDate, YEAR_MONTH_DAY_PATTERN); - }; - - const renderNewVoteGovernanceTokenGainedLabel = () => { - const newTotalStakeAmount = getNewTotalStake(); - if (voteGovernanceTokenBalance === undefined || newTotalStakeAmount === undefined || !isValid) { - return '-'; - } - - const newVoteGovernanceTokenAmountGained = newTotalStakeAmount.sub(voteGovernanceTokenBalance); - const rounded = newVoteGovernanceTokenAmountGained.toBig().round(5); - const typed = newMonetaryAmount(rounded, VOTE_GOVERNANCE_TOKEN, true); - - return `${displayMonetaryAmount(typed)} ${VOTE_GOVERNANCE_TOKEN_SYMBOL}`; - }; - - const getNewTotalStake = () => { - if (remainingBlockNumbersToUnstake === undefined || stakedAmount === undefined || !isValid) { - return undefined; - } - - const extendingLockTime = parseInt(lockTime); // Weeks - - let newLockTime: number; - let newLockingAmount: GovernanceTokenMonetaryAmount; - if (remainingBlockNumbersToUnstake === null) { - // If the user has not staked - newLockTime = extendingLockTime; - newLockingAmount = monetaryLockingAmount; - } else { - // If the user has staked - const currentLockTime = convertBlockNumbersToWeeks(remainingBlockNumbersToUnstake); // Weeks - - // New lock-time that is applied to the entire staked governance token - newLockTime = currentLockTime + extendingLockTime; // Weeks - - // New total staked governance token - newLockingAmount = monetaryLockingAmount.add(stakedAmount); - } - - // Multiplying the new total staked governance token with the staking time divided by the maximum lock time - return newLockingAmount.mul(newLockTime).div(STAKE_LOCK_TIME.MAX); - }; - - const renderNewTotalStakeLabel = () => { - const newTotalStakeAmount = getNewTotalStake(); - if (newTotalStakeAmount === undefined) { - return '-'; - } - - return `${displayMonetaryAmount(newTotalStakeAmount)} ${VOTE_GOVERNANCE_TOKEN_SYMBOL}`; - }; - - const renderEstimatedAPYLabel = () => { - if ( - estimatedRewardAmountAndAPYLoading || - !projectedRewardAmountAndAPY || - errors[LOCK_TIME] || - errors[LOCKING_AMOUNT] - ) { - return '-'; - } - if (estimatedRewardAmountAndAPY === undefined) { - return formatPercentage(projectedRewardAmountAndAPY.apy.toNumber()); - } - - return formatPercentage(estimatedRewardAmountAndAPY.apy.toNumber()); - }; - - const renderEstimatedRewardAmountLabel = () => { - if ( - estimatedRewardAmountAndAPYLoading || - !projectedRewardAmountAndAPY || - errors[LOCK_TIME] || - errors[LOCKING_AMOUNT] - ) { - return '-'; - } - if (estimatedRewardAmountAndAPY === undefined) { - return `${displayMonetaryAmount(projectedRewardAmountAndAPY.amount)} ${GOVERNANCE_TOKEN_SYMBOL}`; - } - - return `${displayMonetaryAmount(estimatedRewardAmountAndAPY.amount)} ${GOVERNANCE_TOKEN_SYMBOL}`; - }; - - const renderClaimableRewardAmountLabel = () => { - if (claimableRewardAmountIdle || claimableRewardAmountLoading) { - return '-'; - } - if (claimableRewardAmount === undefined) { - throw new Error('Something went wrong!'); - } - - return displayMonetaryAmount(claimableRewardAmount); - }; - - const valueInUSDOfLockingAmount = displayMonetaryAmountInUSDFormat( - monetaryLockingAmount, - getTokenPrice(prices, GOVERNANCE_TOKEN_SYMBOL)?.usd - ); - - const handleClickBalance = () => { - setValue(LOCKING_AMOUNT, availableMonetaryBalance || '0'); - trigger(LOCKING_AMOUNT); - }; - - const claimRewardsButtonEnabled = claimableRewardAmount?.gt(ZERO_GOVERNANCE_TOKEN_AMOUNT); - - const unlockFirst = - hasStakedAmount && - // eslint-disable-next-line max-len - // `remainingBlockNumbersToUnstake !== null` is redundant because if `hasStakedAmount` is truthy `remainingBlockNumbersToUnstake` cannot be null - remainingBlockNumbersToUnstake !== null && - remainingBlockNumbersToUnstake !== undefined && - remainingBlockNumbersToUnstake <= 0; - - const accountSet = !!selectedAccount; - - const lockTimeFieldDisabled = - votingBalanceGreaterThanZero === undefined || - remainingBlockNumbersToUnstake === undefined || - availableLockTime === undefined || - availableLockTime <= 0 || - unlockFirst; - - const lockingAmountFieldDisabled = availableBalance === undefined; - - const initializing = - currentBlockNumberIdle || - currentBlockNumberLoading || - voteGovernanceTokenBalanceIdle || - voteGovernanceTokenBalanceLoading || - claimableRewardAmountIdle || - claimableRewardAmountLoading || - projectedRewardAmountAndAPYIdle || - projectedRewardAmountAndAPYLoading || - estimatedRewardAmountAndAPYLoading || - stakedAmountAndEndBlockIdle || - stakedAmountAndEndBlockLoading; - - let submitButtonLabel: string; - if (initializing) { - submitButtonLabel = 'Loading...'; - } else { - if (accountSet) { - // TODO: should improve readability by handling nested conditions - if (votingBalanceGreaterThanZero) { - const numericLockTime = parseInt(lockTime); - if (checkIncreaseLockAmountAndExtendLockTime(numericLockTime, monetaryLockingAmount)) { - submitButtonLabel = 'Add more stake and extend lock time'; - } else if (checkOnlyIncreaseLockAmount(numericLockTime, monetaryLockingAmount)) { - submitButtonLabel = 'Add more stake'; - } else if (checkOnlyExtendLockTime(numericLockTime, monetaryLockingAmount)) { - submitButtonLabel = 'Extend lock time'; - } else { - submitButtonLabel = 'Stake'; - } - } else { - submitButtonLabel = 'Stake'; - } - } else { - submitButtonLabel = t('connect_wallet'); - } - } - - return ( - <> - - -
- - - - {/* eslint-disable-next-line max-len */} - {/* `remainingBlockNumbersToUnstake !== null` is redundant because if `hasStakedAmount` is truthy `remainingBlockNumbersToUnstake` cannot be null */} - {hasStakedAmount && remainingBlockNumbersToUnstake !== null && hasSignature && ( - - )} - -
- - validateLockingAmount(value) - })} - approxUSD={`≈ ${valueInUSDOfLockingAmount}`} - error={!!errors[LOCKING_AMOUNT]} - helperText={errors[LOCKING_AMOUNT]?.message} - disabled={lockingAmountFieldDisabled} - /> -
- validateLockTime(value) - })} - error={!!errors[LOCK_TIME]} - helperText={errors[LOCK_TIME]?.message} - optional={votingBalanceGreaterThanZero} - disabled={lockTimeFieldDisabled} - /> - {votingBalanceGreaterThanZero ? ( - - ) : ( - - )} - - {votingBalanceGreaterThanZero && ( - - )} - - - - {submitButtonLabel}{' '} - {unlockFirst ? ( - - ) : null} - - -
-
- - ); -}; - -export default withErrorBoundary(Staking, { - FallbackComponent: ErrorFallback, - onReset: () => { - window.location.reload(); - } -}); +export default Swap; diff --git a/src/pages/Wallet/WalletOverview/WalletOverview.tsx b/src/pages/Wallet/WalletOverview/WalletOverview.tsx index df181e2468..aa771f9f53 100644 --- a/src/pages/Wallet/WalletOverview/WalletOverview.tsx +++ b/src/pages/Wallet/WalletOverview/WalletOverview.tsx @@ -2,7 +2,6 @@ import { LoanPositionsTable, MainContainer, PoolsTable } from '@/components'; import { useGetAccountPools } from '@/hooks/api/amm/use-get-account-pools'; import { useGetLiquidityPools } from '@/hooks/api/amm/use-get-liquidity-pools'; import { useGetAccountStakingData } from '@/hooks/api/escrow/use-get-account-staking-data'; -import { useGetAccountVotingBalance } from '@/hooks/api/escrow/use-get-account-voting-balance'; import { useGetAccountPositions } from '@/hooks/api/loans/use-get-account-positions'; import { useGetLoanAssets } from '@/hooks/api/loans/use-get-loan-assets'; import { useGetBalances } from '@/hooks/api/tokens/use-get-balances'; @@ -21,7 +20,6 @@ const WalletOverview = (): JSX.Element => { const { data: accountPoolsData } = useGetAccountPools(); const { data: liquidityPools } = useGetLiquidityPools(); const { data: accountStakingData } = useGetAccountStakingData(); - const { data: accountVotingBalance } = useGetAccountVotingBalance(); const { data: { borrowPositions, lendPositions } } = useGetAccountPositions(); @@ -33,11 +31,6 @@ const WalletOverview = (): JSX.Element => { const handleCloseBanner = () => setBannerOpen(false); - const hasStakingTable = - accountStakingData && - accountVotingBalance && - (!accountStakingData?.balance.isZero() || !accountVotingBalance?.isZero()); - const pooledTickers = liquidityPools && getPooledTickers(liquidityPools); return ( @@ -54,7 +47,7 @@ const WalletOverview = (): JSX.Element => { {!!accountPoolsData?.positions.length && ( )} - {hasStakingTable && } + {accountStakingData && } ); }; diff --git a/src/pages/Wallet/WalletOverview/components/StakingTable/StakingTable.tsx b/src/pages/Wallet/WalletOverview/components/StakingTable/StakingTable.tsx index 3a322ed852..1146e2f621 100644 --- a/src/pages/Wallet/WalletOverview/components/StakingTable/StakingTable.tsx +++ b/src/pages/Wallet/WalletOverview/components/StakingTable/StakingTable.tsx @@ -1,5 +1,3 @@ -import { CurrencyExt } from '@interlay/interbtc-api'; -import { MonetaryAmount } from '@interlay/monetary-js'; import { useId } from '@react-aria/utils'; import { differenceInDays, format, formatDistanceToNowStrict } from 'date-fns'; import { ReactNode, useMemo } from 'react'; @@ -9,7 +7,7 @@ import { convertMonetaryAmountToValueInUSD, formatUSD } from '@/common/utils/uti import { CoinIcon, Flex } from '@/component-library'; import { Cell, Table } from '@/components'; import { GOVERNANCE_TOKEN, VOTE_GOVERNANCE_TOKEN } from '@/config/relay-chains'; -import { GetAccountStakingData } from '@/hooks/api/escrow/use-get-account-staking-data'; +import { AccountStakingData } from '@/hooks/api/escrow/use-get-account-staking-data'; import { useGetBalances } from '@/hooks/api/tokens/use-get-balances'; import { useGetPrices } from '@/hooks/api/use-get-prices'; import { YEAR_MONTH_DAY_PATTERN } from '@/utils/constants/date-time'; @@ -31,11 +29,10 @@ type StakingTableRows = { }; type StakingTableProps = { - data: GetAccountStakingData; - votingBalance: MonetaryAmount; + data: AccountStakingData; }; -const StakingTable = ({ data, votingBalance }: StakingTableProps): JSX.Element => { +const StakingTable = ({ data }: StakingTableProps): JSX.Element => { const { t } = useTranslation(); const titleId = useId(); const prices = useGetPrices(); @@ -55,7 +52,7 @@ const StakingTable = ({ data, votingBalance }: StakingTableProps): JSX.Element = ]; const rows = useMemo((): StakingTableRows[] => { - const { balance, unlock } = data; + const { balance, unlock, votingBalance } = data; const stakingBalancePrice = convertMonetaryAmountToValueInUSD(balance, getTokenPrice(prices, balance.currency.ticker)?.usd) || 0; @@ -88,7 +85,7 @@ const StakingTable = ({ data, votingBalance }: StakingTableProps): JSX.Element = } ]; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [prices, data, votingBalance]); + }, [prices, data]); return ; }; diff --git a/src/test/mocks/@interlay/interbtc-api/index.ts b/src/test/mocks/@interlay/interbtc-api/index.ts index 1a13b8bf07..e32b7513ec 100644 --- a/src/test/mocks/@interlay/interbtc-api/index.ts +++ b/src/test/mocks/@interlay/interbtc-api/index.ts @@ -8,6 +8,7 @@ import { Signer } from '@polkadot/types/types'; import { MOCK_AMM, MOCK_API, + MOCK_ESCROW, MOCK_LOANS, MOCK_SYSTEM, MOCK_TOKENS, @@ -36,7 +37,6 @@ import { mockVaultsGetVaultsWithRedeemableTokens } from './parachain'; import { mockGetForeignAssets } from './parachain/assetRegistry'; -import { mockGetStakedBalance, mockVotingBalance } from './parachain/escrow'; const mockSetAccount = jest.fn((_account: AddressOrPair, _signer?: Signer) => undefined); @@ -88,10 +88,7 @@ const mockInterBtcApi: Partial> = { getVaultsWithRedeemableTokens: mockVaultsGetVaultsWithRedeemableTokens }, amm: MOCK_AMM.MODULE, - escrow: { - getStakedBalance: mockGetStakedBalance, - votingBalance: mockVotingBalance - }, + escrow: MOCK_ESCROW.MODULE, transaction: MOCK_TRANSACTION.MODULE }; diff --git a/src/test/mocks/@interlay/interbtc-api/parachain/api.ts b/src/test/mocks/@interlay/interbtc-api/parachain/api.ts index 3381938359..4f707e4595 100644 --- a/src/test/mocks/@interlay/interbtc-api/parachain/api.ts +++ b/src/test/mocks/@interlay/interbtc-api/parachain/api.ts @@ -1,8 +1,11 @@ +import { newMonetaryAmount } from '@interlay/interbtc-api'; import { ApiPromise } from '@polkadot/api'; import { Text, TypeRegistry } from '@polkadot/types'; import { Registry } from '@polkadot/types/types'; import Big from 'big.js'; +import { GOVERNANCE_TOKEN } from '@/config/relay-chains'; + import { EXTRINSIC } from '../extrinsic'; const REGISTRY = ({ chainDecimals: [], chainSS58: 0, chainTokens: [] } as unknown) as Registry; @@ -22,7 +25,9 @@ const DATA = { VESTING_SCHEDULES }; // add here mocks that are being manipulated in tests const MODULE = { vestingSchedules: jest.fn().mockReturnValue(VESTING_SCHEDULES.EMPTY), - claimVesting: jest.fn().mockReturnValue(EXTRINSIC) + claimVesting: jest.fn().mockReturnValue(EXTRINSIC), + batchAll: jest.fn().mockReturnValue(EXTRINSIC), + freeStakable: jest.fn().mockResolvedValue(newMonetaryAmount(10000000000000, GOVERNANCE_TOKEN, true)) }; // maps module to ApiPromise @@ -33,6 +38,9 @@ const PROMISE: Partial> = { system: { chain: jest.fn().mockReturnValue(SYSTEM_CHAIN), chainType: jest.fn().mockReturnValue(CHAIN_TYPE) + }, + escrow: { + freeStakable: MODULE.freeStakable } }, query: { @@ -51,6 +59,9 @@ const PROMISE: Partial> = { }, multiTransactionPayment: { withFeeSwapPath: jest.fn().mockReturnValue(EXTRINSIC) + }, + utility: { + batchAll: MODULE.batchAll } } }; diff --git a/src/test/mocks/@interlay/interbtc-api/parachain/escrow.ts b/src/test/mocks/@interlay/interbtc-api/parachain/escrow.ts index 8d27cc7621..2cd3a265cf 100644 --- a/src/test/mocks/@interlay/interbtc-api/parachain/escrow.ts +++ b/src/test/mocks/@interlay/interbtc-api/parachain/escrow.ts @@ -1,25 +1,70 @@ import '@testing-library/jest-dom'; -import { newMonetaryAmount } from '@interlay/interbtc-api'; +import { CurrencyExt, EscrowAPI, newMonetaryAmount, StakedBalance } from '@interlay/interbtc-api'; +import { MonetaryAmount } from '@interlay/monetary-js'; +import Big from 'big.js'; -import { GOVERNANCE_TOKEN } from '@/config/relay-chains'; +import { GOVERNANCE_TOKEN, STAKE_LOCK_TIME, VOTE_GOVERNANCE_TOKEN } from '@/config/relay-chains'; +import { convertWeeksToBlockNumbers } from '@/utils/helpers/staking'; -const DEFAULT_STAKED_AMOUNT = newMonetaryAmount(10, GOVERNANCE_TOKEN, true); +import { EXTRINSIC_DATA } from '../extrinsic'; +import { MOCK_SYSTEM } from './system'; -const DEFAULT_STAKED_BALANCE = { amount: DEFAULT_STAKED_AMOUNT, endBlock: 0 }; +const GOVERNANCE_AMOUNT = { + EMPTY: { + VALUE: '0', + MONETARY: newMonetaryAmount(0, GOVERNANCE_TOKEN, true) + }, + FULL: { + VALUE: '100', + MONETARY: newMonetaryAmount(100, GOVERNANCE_TOKEN, true) + } +}; -const EMPTY_STAKED_BALANCE = { amount: newMonetaryAmount(0, GOVERNANCE_TOKEN), endBlock: 0 }; +const VOTE_AMOUNT = { + EMPTY: { + VALUE: '0', + MONETARY: newMonetaryAmount(0, VOTE_GOVERNANCE_TOKEN, true) + }, + FULL: { + VALUE: '100', + MONETARY: newMonetaryAmount(100, VOTE_GOVERNANCE_TOKEN, true) + } +}; -const mockGetStakedBalance = jest.fn().mockResolvedValue(DEFAULT_STAKED_BALANCE); +const STAKED_BALANCE: Record<'EMPTY' | 'FULL' | 'FULL_LOCK_TIME', StakedBalance> = { + EMPTY: { amount: GOVERNANCE_AMOUNT.EMPTY.MONETARY, endBlock: MOCK_SYSTEM.DATA.BLOCK_NUMBER.CURRENT }, + FULL: { amount: GOVERNANCE_AMOUNT.FULL.MONETARY, endBlock: MOCK_SYSTEM.DATA.BLOCK_NUMBER.CURRENT + 1 }, + FULL_LOCK_TIME: { amount: GOVERNANCE_AMOUNT.FULL.MONETARY, endBlock: convertWeeksToBlockNumbers(STAKE_LOCK_TIME.MAX) } +}; -const DEFAULT_VOTING_BALANCE = newMonetaryAmount(10, GOVERNANCE_TOKEN, true); +const REWARD_ESTIMATE: Record<'EMPTY' | 'FULL', { amount: MonetaryAmount; apy: Big }> = { + EMPTY: { apy: new Big(0), amount: GOVERNANCE_AMOUNT.EMPTY.MONETARY }, + FULL: { apy: new Big(10), amount: GOVERNANCE_AMOUNT.FULL.MONETARY } +}; -const mockVotingBalance = jest.fn().mockResolvedValue(DEFAULT_VOTING_BALANCE); +const DATA = { GOVERNANCE_AMOUNT, STAKED_BALANCE }; -export { - DEFAULT_STAKED_BALANCE, - DEFAULT_VOTING_BALANCE, - EMPTY_STAKED_BALANCE, - mockGetStakedBalance, - mockVotingBalance +const MODULE: Record> = { + getStakedBalance: jest.fn().mockResolvedValue(STAKED_BALANCE.FULL), + votingBalance: jest.fn().mockResolvedValue(VOTE_AMOUNT.EMPTY.MONETARY), + getRewardEstimate: jest.fn().mockResolvedValue(REWARD_ESTIMATE.FULL), + totalVotingSupply: jest.fn().mockResolvedValue(GOVERNANCE_AMOUNT.FULL.MONETARY), + getTotalStakedBalance: jest.fn().mockResolvedValue(GOVERNANCE_AMOUNT.FULL.MONETARY), + getMaxPeriod: jest.fn(), + getRewards: jest.fn().mockResolvedValue(GOVERNANCE_AMOUNT.FULL.MONETARY), + getSpan: jest.fn(), + // MUTATIONS + createLock: jest.fn().mockReturnValue(EXTRINSIC_DATA), + increaseAmount: jest.fn().mockReturnValue(EXTRINSIC_DATA), + increaseUnlockHeight: jest.fn().mockReturnValue(EXTRINSIC_DATA), + withdraw: jest.fn().mockReturnValue(EXTRINSIC_DATA), + withdrawRewards: jest.fn().mockReturnValue(EXTRINSIC_DATA) }; + +const MOCK_ESCROW = { + DATA, + MODULE +}; + +export { MOCK_ESCROW }; diff --git a/src/test/mocks/@interlay/interbtc-api/parachain/index.ts b/src/test/mocks/@interlay/interbtc-api/parachain/index.ts index 946802c646..ea1a6fbb20 100644 --- a/src/test/mocks/@interlay/interbtc-api/parachain/index.ts +++ b/src/test/mocks/@interlay/interbtc-api/parachain/index.ts @@ -2,6 +2,7 @@ export * from './amm'; export * from './api'; export * from './btcRelay'; export * from './electrsAPI'; +export * from './escrow'; export * from './fee'; export * from './issue'; export * from './loans'; diff --git a/src/test/pages/Staking.test.tsx b/src/test/pages/Staking.test.tsx new file mode 100644 index 0000000000..b80bbb813e --- /dev/null +++ b/src/test/pages/Staking.test.tsx @@ -0,0 +1,266 @@ +import MatchMediaMock from 'jest-matchmedia-mock'; + +import App from '@/App'; +import { STAKE_LOCK_TIME } from '@/config/relay-chains'; +import { convertWeeksToBlockNumbers } from '@/utils/helpers/staking'; + +import { MOCK_API, MOCK_ESCROW, MOCK_SYSTEM } from '../mocks/@interlay/interbtc-api'; +import { EXTRINSIC_DATA } from '../mocks/@interlay/interbtc-api/extrinsic'; +import { DEFAULT_ACCOUNT_1 } from '../mocks/substrate/mocks'; +import { render, screen, userEvent, waitFor, within } from '../test-utils'; +import { waitForFeeEstimate, waitForTransactionExecute } from './utils/transaction'; + +const { STAKED_BALANCE, GOVERNANCE_AMOUNT } = MOCK_ESCROW.DATA; +const { + getStakedBalance, + createLock, + increaseAmount, + increaseUnlockHeight, + getRewardEstimate, + withdraw, + withdrawRewards +} = MOCK_ESCROW.MODULE; +const { batchAll } = MOCK_API.MODULE; +const { getCurrentBlockNumber } = MOCK_SYSTEM.MODULE; + +jest.mock('@/components/Layout', () => { + const MockedLayout: React.FC = ({ children }: any) => children; + MockedLayout.displayName = 'MockedLayout'; + return { + Layout: MockedLayout + }; +}); + +const path = '/staking'; + +const ONE_WEEK = 1; + +const ONE_WEEK_UNLOCK_HEIGHT = convertWeeksToBlockNumbers(ONE_WEEK) + STAKED_BALANCE.FULL.endBlock; + +describe('Staking Page', () => { + let matchMedia: MatchMediaMock; + + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date('2023-01-01T00:00:00.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + matchMedia = new MatchMediaMock(); + + getStakedBalance.mockResolvedValue(STAKED_BALANCE.FULL); + }); + + afterEach(() => { + matchMedia.clear(); + }); + + it('should be able to do initial stake', async () => { + getStakedBalance.mockResolvedValue(STAKED_BALANCE.EMPTY); + + await render(, { path }); + + userEvent.type(screen.getByRole('textbox', { name: /amount/i }), GOVERNANCE_AMOUNT.FULL.VALUE); + + userEvent.type(screen.getByRole('textbox', { name: /lock time/i, exact: false }), ONE_WEEK.toString()); + + await waitForFeeEstimate(createLock); + + const unlockHeight = convertWeeksToBlockNumbers(ONE_WEEK); + + expect(getRewardEstimate).toHaveBeenLastCalledWith( + DEFAULT_ACCOUNT_1.address, + GOVERNANCE_AMOUNT.FULL.MONETARY, + unlockHeight + ); + + expect(screen.getByText('08/01/23')).toBeInTheDocument(); + + userEvent.click(screen.getByRole('button', { name: /stake/i })); + + await waitForTransactionExecute(createLock); + + expect(createLock).toHaveBeenCalledWith(GOVERNANCE_AMOUNT.FULL.MONETARY, unlockHeight); + }); + + it('should be able to increase stake and lock time amount', async () => { + await render(, { path }); + + userEvent.type(screen.getByRole('textbox', { name: /amount/i }), GOVERNANCE_AMOUNT.FULL.VALUE); + + userEvent.type(screen.getByRole('textbox', { name: /extended lock time/i, exact: false }), ONE_WEEK.toString()); + + await waitForFeeEstimate(batchAll); + + userEvent.click(screen.getByRole('button', { name: /stake/i })); + + await waitForTransactionExecute(batchAll); + + expect(increaseAmount).toHaveBeenCalledWith(GOVERNANCE_AMOUNT.FULL.MONETARY); + expect(increaseUnlockHeight).toHaveBeenCalledWith(ONE_WEEK_UNLOCK_HEIGHT); + expect(batchAll).toHaveBeenCalledWith([EXTRINSIC_DATA.extrinsic, EXTRINSIC_DATA.extrinsic]); + }); + + it('should be able to increase stake amount when lock time is empty', async () => { + await render(, { path }); + + userEvent.type(screen.getByRole('textbox', { name: /amount/i }), GOVERNANCE_AMOUNT.FULL.VALUE); + + await waitForFeeEstimate(increaseAmount); + + userEvent.click(screen.getByRole('button', { name: /stake/i })); + + await waitForTransactionExecute(increaseAmount); + + expect(increaseAmount).toHaveBeenCalledWith(GOVERNANCE_AMOUNT.FULL.MONETARY); + }); + + it('should be able to increase stake amount when lock time is 0', async () => { + await render(, { path }); + + userEvent.type(screen.getByRole('textbox', { name: /amount/i }), GOVERNANCE_AMOUNT.FULL.VALUE); + + userEvent.type(screen.getByRole('textbox', { name: /extended lock time/i, exact: false }), '0'); + + await waitForFeeEstimate(increaseAmount); + + userEvent.click(screen.getByRole('button', { name: /stake/i })); + + await waitForTransactionExecute(increaseAmount); + + expect(increaseAmount).toHaveBeenCalledWith(GOVERNANCE_AMOUNT.FULL.MONETARY); + }); + + it('should be able to increase stake lock time', async () => { + await render(, { path }); + + userEvent.type(screen.getByRole('textbox', { name: /extended lock time/i, exact: false }), ONE_WEEK.toString()); + + await waitForFeeEstimate(increaseUnlockHeight); + + userEvent.click(screen.getByRole('button', { name: /stake/i })); + + await waitForTransactionExecute(increaseUnlockHeight); + + expect(increaseUnlockHeight).toHaveBeenCalledWith(ONE_WEEK_UNLOCK_HEIGHT); + }); + + it('should be able to set lock time using list', async () => { + getStakedBalance.mockResolvedValue(STAKED_BALANCE.EMPTY); + + await render(, { path }); + + const grid = within(screen.getByRole('grid', { name: /lock time/i, exact: false })); + + userEvent.click(grid.getByRole('gridcell', { name: /1 week/i })); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /lock time/i, exact: false })).toHaveValue(ONE_WEEK.toString()); + }); + + userEvent.click(grid.getByRole('gridcell', { name: /1 month/i })); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /lock time/i, exact: false })).toHaveValue('4'); + }); + + userEvent.click(grid.getByRole('gridcell', { name: /3 month/i })); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /lock time/i, exact: false })).toHaveValue('13'); + }); + + userEvent.click(grid.getByRole('gridcell', { name: /6 month/i })); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /lock time/i, exact: false })).toHaveValue('26'); + }); + + userEvent.click(grid.getByRole('gridcell', { name: /max/i })); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /lock time/i, exact: false })).toHaveValue( + STAKE_LOCK_TIME.MAX.toString() + ); + }); + }); + + it('should be able to withdraw stake', async () => { + getCurrentBlockNumber.mockResolvedValue(STAKED_BALANCE.FULL.endBlock); + + await render(, { path }); + + userEvent.type(screen.getByRole('textbox', { name: /amount/i }), GOVERNANCE_AMOUNT.FULL.VALUE); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByRole('button', { name: /withdraw/i })); + + await waitForFeeEstimate(withdraw); + + const dialog = within(screen.getByRole('dialog', { name: /withdraw/i, exact: false })); + + userEvent.click(dialog.getByRole('button', { name: /withdraw/i })); + + await waitForTransactionExecute(withdraw); + }); + + it('should be able to claim rewards', async () => { + await render(, { path }); + + userEvent.click(screen.getByRole('button', { name: /claim/i })); + + await waitForFeeEstimate(withdrawRewards); + + const dialog = within(screen.getByRole('dialog', { name: /claim rewards/i })); + + userEvent.click(dialog.getByRole('button', { name: /claim/i })); + + await waitForTransactionExecute(withdrawRewards); + }); + + it('should not be able to extend lock time due to input being disable (account already maxed lock time)', async () => { + getStakedBalance.mockResolvedValue(STAKED_BALANCE.FULL_LOCK_TIME); + + await render(, { path }); + + expect(screen.getByRole('textbox', { name: /extended lock time/i, exact: false })).toBeDisabled(); + + expect(screen.getAllByLabelText(/max 0/i)).toHaveLength(2); + + const grid = within(screen.getByRole('grid', { name: /lock time/i, exact: false })); + + const rows = grid.getAllByRole('row'); + + rows.forEach((row) => { + expect(row).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('should not be able to extend lock time due to exceeding max', async () => { + getStakedBalance.mockResolvedValue(STAKED_BALANCE.EMPTY); + + await render(, { path }); + + userEvent.type( + screen.getByRole('textbox', { name: new RegExp(`max ${STAKE_LOCK_TIME.MAX}`, 'i'), exact: false }), + (STAKE_LOCK_TIME.MAX + ONE_WEEK).toString() + ); + + userEvent.type(screen.getByRole('textbox', { name: /amount/i }), GOVERNANCE_AMOUNT.FULL.VALUE); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: new RegExp(`max ${STAKE_LOCK_TIME.MAX}`, 'i') })).toHaveErrorMessage( + '' + ); + }); + + expect(createLock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/pages/Wallet.test.tsx b/src/test/pages/Wallet.test.tsx index 855554869b..8b5f180d66 100644 --- a/src/test/pages/Wallet.test.tsx +++ b/src/test/pages/Wallet.test.tsx @@ -6,12 +6,7 @@ import { GOVERNANCE_TOKEN, RELAY_CHAIN_NATIVE_TOKEN, WRAPPED_TOKEN } from '@/con import { NATIVE_CURRENCIES } from '@/utils/constants/currency'; import { PAGES, QUERY_PARAMETERS } from '@/utils/constants/links'; -import { MOCK_AMM, MOCK_API, MOCK_LOANS, MOCK_SYSTEM } from '../mocks/@interlay/interbtc-api'; -import { - DEFAULT_STAKED_BALANCE, - EMPTY_STAKED_BALANCE, - mockGetStakedBalance -} from '../mocks/@interlay/interbtc-api/parachain/escrow'; +import { MOCK_AMM, MOCK_API, MOCK_ESCROW, MOCK_LOANS, MOCK_SYSTEM } from '../mocks/@interlay/interbtc-api'; import { render, screen, userEvent, waitFor } from '../test-utils'; import { withinList } from './utils/list'; import { queryTable, withinTable, withinTableRow } from './utils/table'; @@ -22,11 +17,13 @@ const { getLpTokens, getLiquidityProvidedByAccount } = MOCK_AMM.MODULE; const { getCurrentBlockNumber } = MOCK_SYSTEM.MODULE; const { getLendPositionsOfAccount, getBorrowPositionsOfAccount } = MOCK_LOANS.MODULE; const { claimVesting, vestingSchedules } = MOCK_API.MODULE; +const { getStakedBalance } = MOCK_ESCROW.MODULE; const { ACCOUNT_LIQUIDITY } = MOCK_AMM.DATA; const { BLOCK_NUMBER } = MOCK_SYSTEM.DATA; const { LOAN_POSITIONS } = MOCK_LOANS.DATA; const { VESTING_SCHEDULES } = MOCK_API.DATA; +const { STAKED_BALANCE } = MOCK_ESCROW.DATA; const path = '/wallet'; @@ -49,7 +46,7 @@ describe('Wallet Page', () => { getLendPositionsOfAccount.mockReturnValue(LOAN_POSITIONS.LEND.AVERAGE); getBorrowPositionsOfAccount.mockReturnValue(LOAN_POSITIONS.BORROW.AVERAGE); getLiquidityProvidedByAccount.mockReturnValue(ACCOUNT_LIQUIDITY.EMPTY); - mockGetStakedBalance.mockReturnValue(DEFAULT_STAKED_BALANCE); + getStakedBalance.mockReturnValue(STAKED_BALANCE.FULL); getCurrentBlockNumber.mockReturnValue(BLOCK_NUMBER.CURRENT); vestingSchedules.mockReturnValue(VESTING_SCHEDULES.EMPTY); }); @@ -225,7 +222,7 @@ describe('Wallet Page', () => { }); it('should not display table', async () => { - mockGetStakedBalance.mockReturnValue(EMPTY_STAKED_BALANCE); + getStakedBalance.mockResolvedValue(STAKED_BALANCE.EMPTY); await render(, { path }); diff --git a/src/utils/helpers/staking.ts b/src/utils/helpers/staking.ts new file mode 100644 index 0000000000..4620dff020 --- /dev/null +++ b/src/utils/helpers/staking.ts @@ -0,0 +1,13 @@ +import { BLOCK_TIME } from '@/config/parachain'; + +const ONE_WEEK_SECONDS = 7 * 24 * 3600; + +const convertWeeksToBlockNumbers = (weeks: number): number => { + return (weeks * ONE_WEEK_SECONDS) / BLOCK_TIME; +}; + +const convertBlockNumbersToWeeks = (blockNumbers: number): number => { + return (blockNumbers * BLOCK_TIME) / ONE_WEEK_SECONDS; +}; + +export { convertBlockNumbersToWeeks, convertWeeksToBlockNumbers };