From b1e268a3261df99fd6cd01c8778beeef2981a3a5 Mon Sep 17 00:00:00 2001 From: Ostap Piatkovskyi <44294945+ost-ptk@users.noreply.github.com> Date: Thu, 16 Nov 2023 15:27:35 +0200 Subject: [PATCH] feature: add delegation and undelegation flow (#846) * added delegation and undelegation flow * fixed issue with sticky header in tabs * added a new modal window with buttons on the home page * added empty state UI for undelegation * fixed deploys tab and deploys details page for staking --------- Co-authored-by: ost-ptk Co-authored-by: Vynnyk Dmytro --- src/apps/popup/app-router.tsx | 3 + .../popup/pages/activity-details/content.tsx | 29 +- .../components/more-buttons-modal/index.tsx | 49 ++ .../more-buttons-modal/modal-buttons.tsx | 119 +++ .../home/components/tokens-list/index.tsx | 6 +- src/apps/popup/pages/home/index.tsx | 56 +- src/apps/popup/pages/stakes/amount-step.tsx | 143 ++++ src/apps/popup/pages/stakes/confirm-step.tsx | 134 ++++ src/apps/popup/pages/stakes/content.tsx | 131 ++++ src/apps/popup/pages/stakes/index.tsx | 412 ++++++++++ .../popup/pages/stakes/no-delegations.tsx | 34 + .../popup/pages/stakes/validator-step.tsx | 29 + src/apps/popup/pages/transfer-nft/index.tsx | 2 +- src/apps/popup/pages/transfer/content.tsx | 2 +- src/apps/popup/pages/transfer/index.tsx | 4 +- src/apps/popup/router/paths.ts | 4 +- src/apps/popup/router/types.ts | 4 +- src/assets/icons/burn.svg | 3 + src/assets/icons/delegate.svg | 3 + src/assets/icons/empty-state.svg | 739 ++++++++++++++++++ src/assets/icons/undelegate.svg | 3 + src/background/index.ts | 43 + src/background/redux/settings/selectors.ts | 7 +- src/background/service-message.ts | 18 +- src/constants.ts | 88 ++- .../header/header-connection-status.tsx | 1 + .../layout/header/header-network-switcher.tsx | 1 + .../header/header-submenu-bar-nav-link.tsx | 42 +- .../account-activity-service/types.ts | 1 + src/libs/services/deployer-service/index.ts | 74 +- .../services/validators-service/constants.ts | 9 + src/libs/services/validators-service/index.ts | 3 + src/libs/services/validators-service/types.ts | 124 +++ .../validators-service/validators-service.ts | 75 ++ .../account-activity-plate.tsx | 86 +- .../account-casper-activity-plate.tsx | 28 +- src/libs/ui/components/button/button.tsx | 1 - src/libs/ui/components/error/error.tsx | 39 + src/libs/ui/components/hash/utils.ts | 4 +- src/libs/ui/components/input/input.tsx | 4 +- src/libs/ui/components/list/list.tsx | 18 +- src/libs/ui/components/modal/modal.tsx | 31 +- .../recipient-plate/recipient-plate.tsx | 2 +- src/libs/ui/components/tabs/tabs.tsx | 2 +- src/libs/ui/components/tile/tile.tsx | 5 +- .../transfer-succeess-screen.tsx | 10 +- .../validator-dropdown-input.tsx | 231 ++++++ .../validator-plate/validator-plate.tsx | 188 +++++ src/libs/ui/forms/form-validation-rules.ts | 122 ++- src/libs/ui/forms/stakes-form.ts | 50 ++ src/libs/ui/forms/transfer.ts | 4 +- src/libs/ui/index.ts | 3 + 52 files changed, 3066 insertions(+), 157 deletions(-) create mode 100644 src/apps/popup/pages/home/components/more-buttons-modal/index.tsx create mode 100644 src/apps/popup/pages/home/components/more-buttons-modal/modal-buttons.tsx create mode 100644 src/apps/popup/pages/stakes/amount-step.tsx create mode 100644 src/apps/popup/pages/stakes/confirm-step.tsx create mode 100644 src/apps/popup/pages/stakes/content.tsx create mode 100644 src/apps/popup/pages/stakes/index.tsx create mode 100644 src/apps/popup/pages/stakes/no-delegations.tsx create mode 100644 src/apps/popup/pages/stakes/validator-step.tsx create mode 100644 src/assets/icons/burn.svg create mode 100644 src/assets/icons/delegate.svg create mode 100644 src/assets/icons/empty-state.svg create mode 100644 src/assets/icons/undelegate.svg create mode 100644 src/libs/services/validators-service/constants.ts create mode 100644 src/libs/services/validators-service/index.ts create mode 100644 src/libs/services/validators-service/types.ts create mode 100644 src/libs/services/validators-service/validators-service.ts create mode 100644 src/libs/ui/components/error/error.tsx create mode 100644 src/libs/ui/components/validator-dropdown-input/validator-dropdown-input.tsx create mode 100644 src/libs/ui/components/validator-plate/validator-plate.tsx create mode 100644 src/libs/ui/forms/stakes-form.ts diff --git a/src/apps/popup/app-router.tsx b/src/apps/popup/app-router.tsx index 983098e99..10691c271 100644 --- a/src/apps/popup/app-router.tsx +++ b/src/apps/popup/app-router.tsx @@ -37,6 +37,7 @@ import { NftDetailsPage } from '@popup/pages/nft-details'; import { WalletQrCodePage } from '@popup/pages/wallet-qr-code'; import { TransferNftPage } from '@popup/pages/transfer-nft'; import { ChangePasswordPage } from '@popup/pages/change-password'; +import { StakesPage } from '@popup/pages/stakes'; export function AppRouter() { const isLocked = useSelector(selectVaultIsLocked); @@ -252,6 +253,8 @@ function AppRoutes() { path={RouterPath.ChangePassword} element={} /> + } /> + } /> ); } diff --git a/src/apps/popup/pages/activity-details/content.tsx b/src/apps/popup/pages/activity-details/content.tsx index 838fa18d5..619a69303 100644 --- a/src/apps/popup/pages/activity-details/content.tsx +++ b/src/apps/popup/pages/activity-details/content.tsx @@ -41,8 +41,9 @@ import { } from '@libs/ui/utils/formatters'; import { getBlockExplorerContractUrl, - TransferType, - TypeName + ActivityType, + ActivityTypeName, + AuctionManagerEntryPoint } from '@src/constants'; import { selectApiConfigBasedOnActiveNetwork } from '@background/redux/settings/selectors'; @@ -50,7 +51,7 @@ interface ActivityDetailsPageContentProps { fromAccount?: string; toAccount?: string; deployInfo?: ExtendedDeploy | null; - type?: TransferType | null; + type?: ActivityType | null; amount?: string | null; symbol?: string | null; } @@ -80,8 +81,11 @@ const AddressContainer = styled(FlexColumn)` padding: 16px 12px 16px 0; `; -const AmountContainer = styled(AlignedSpaceBetweenFlexRow)` - padding: 8px 16px 8px 0; +const AmountContainer = styled(AlignedSpaceBetweenFlexRow)<{ + emptyAmount?: boolean; +}>` + padding: ${({ emptyAmount }) => + emptyAmount ? '16px 16px 16px 0' : '8px 16px 8px 0'}; `; const RowsContainer = styled(FlexColumn)` @@ -117,7 +121,10 @@ export const ActivityDetailsPageContent = ({ [Erc20EventType.erc20_transfer_from]: t('Transfer from'), [Erc20EventType.erc20_approve]: t('Approve of'), [Erc20EventType.erc20_burn]: t('Burn of'), - [Erc20EventType.erc20_mint]: t('Mint of') + [Erc20EventType.erc20_mint]: t('Mint of'), + [AuctionManagerEntryPoint.delegate]: t('Delegate with'), + [AuctionManagerEntryPoint.undelegate]: t('Undelegate with'), + [AuctionManagerEntryPoint.redelegate]: t('Redelegate with') }; const decimals = deployInfo.contractPackage?.metadata?.decimals; @@ -173,7 +180,7 @@ export const ActivityDetailsPageContent = ({ - {type && {TypeName[type]}} + {type && {ActivityTypeName[type]}} @@ -285,7 +292,11 @@ export const ActivityDetailsPageContent = ({ } /> - contract + + {deployInfo.contractPackage.contract_name === 'Auction' + ? 'System Contract' + : 'contract'} + @@ -295,7 +306,7 @@ export const ActivityDetailsPageContent = ({ )} - + Amount diff --git a/src/apps/popup/pages/home/components/more-buttons-modal/index.tsx b/src/apps/popup/pages/home/components/more-buttons-modal/index.tsx new file mode 100644 index 000000000..5d834395e --- /dev/null +++ b/src/apps/popup/pages/home/components/more-buttons-modal/index.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import { Button, Modal, SvgIcon, Typography } from '@libs/ui'; +import { CenteredFlexColumn, SpacingSize } from '@libs/layout'; + +import { ModalButtons } from './modal-buttons'; + +const MoreButton = styled(CenteredFlexColumn)` + cursor: pointer; + + padding: 0 16px; +`; + +interface MoreButtonsModalProps { + handleBuyWithCSPR: () => void; +} + +export const MoreButtonsModal = ({ + handleBuyWithCSPR +}: MoreButtonsModalProps) => { + const { t } = useTranslation(); + + return ( + ( + + )} + children={() => ( + + + + More + + + )} + /> + ); +}; diff --git a/src/apps/popup/pages/home/components/more-buttons-modal/modal-buttons.tsx b/src/apps/popup/pages/home/components/more-buttons-modal/modal-buttons.tsx new file mode 100644 index 000000000..4625ca82b --- /dev/null +++ b/src/apps/popup/pages/home/components/more-buttons-modal/modal-buttons.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { Trans, useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import { NetworkSetting } from '@src/constants'; +import { AlignedFlexRow, FlexColumn, SpacingSize } from '@libs/layout'; +import { Button, SvgIcon, Typography } from '@libs/ui'; +import { RouterPath, useTypedNavigate } from '@popup/router'; +import { useCasperToken } from '@src/hooks'; +import { selectActiveNetworkSetting } from '@background/redux/settings/selectors'; + +const ButtonContainer = styled(AlignedFlexRow)` + cursor: pointer; + + padding: 14px 16px; +`; + +interface ButtonsProps { + handleBuyWithCSPR: () => void; +} + +export const ModalButtons = ({ handleBuyWithCSPR }: ButtonsProps) => { + const { t } = useTranslation(); + const navigate = useTypedNavigate(); + const casperToken = useCasperToken(); + + const network = useSelector(selectActiveNetworkSetting); + + return ( + + {network === NetworkSetting.Mainnet && ( + + + + + Buy + + + Buy CSPR with cash + + + + )} + + navigate( + casperToken?.id + ? RouterPath.Transfer.replace( + ':tokenContractPackageHash', + casperToken.id + ).replace( + ':tokenContractHash', + casperToken.contractHash || 'null' + ) + : RouterPath.TransferNoParams + ) + } + > + + + + Send + + + Send CSPR to any account + + + + + navigate(RouterPath.Receive, { + state: { tokenData: casperToken } + }) + } + > + + + + Receive + + + Receive CSPR + + + + navigate(RouterPath.Delegate)} + > + + + Delegate + + + navigate(RouterPath.Undelegate)} + > + + + Undelegate + + + + ); +}; diff --git a/src/apps/popup/pages/home/components/tokens-list/index.tsx b/src/apps/popup/pages/home/components/tokens-list/index.tsx index 6e63d6b93..812cd91f1 100644 --- a/src/apps/popup/pages/home/components/tokens-list/index.tsx +++ b/src/apps/popup/pages/home/components/tokens-list/index.tsx @@ -12,8 +12,8 @@ import { formatErc20TokenBalance } from './utils'; const TotalValueContainer = styled(SpaceBetweenFlexRow)` padding: 12px 16px; - border-top-left-radius: 12px; - border-top-right-radius: 12px; + border-top-left-radius: ${({ theme }) => theme.borderRadius.twelve}px; + border-top-right-radius: ${({ theme }) => theme.borderRadius.twelve}px; background-color: ${({ theme }) => theme.color.backgroundPrimary}; `; @@ -77,7 +77,7 @@ export const TokensList = () => { /> )} marginLeftForItemSeparatorLine={56} - marginLeftForHeaderSeparatorLine={16} + marginLeftForHeaderSeparatorLine={0} /> ); }; diff --git a/src/apps/popup/pages/home/index.tsx b/src/apps/popup/pages/home/index.tsx index 326ae24f7..a8a36f677 100644 --- a/src/apps/popup/pages/home/index.tsx +++ b/src/apps/popup/pages/home/index.tsx @@ -55,6 +55,7 @@ import { import { TokensList } from './components/tokens-list'; import { NftList } from './components/nft-list'; import { DeploysList } from './components/deploys-list'; +import { MoreButtonsModal } from './components/more-buttons-modal'; const DividerLine = styled.hr` margin: 16px 0; @@ -65,11 +66,13 @@ const DividerLine = styled.hr` `; const ButtonsContainer = styled(CenteredFlexRow)` - margin-top: 28px; + margin-top: 16px; `; const ButtonContainer = styled(CenteredFlexColumn)` cursor: pointer; + + padding: 0 16px; `; export function HomePageContent() { @@ -166,6 +169,22 @@ export function HomePageContent() { + {network === NetworkSetting.Mainnet && ( + + + + Buy + + + )} @@ -192,40 +211,7 @@ export function HomePageContent() { Send - - navigate(RouterPath.Receive, { - state: { tokenData: casperToken } - }) - } - > - - - Receive - - - {network === NetworkSetting.Mainnet && ( - - - - Buy - - - )} + diff --git a/src/apps/popup/pages/stakes/amount-step.tsx b/src/apps/popup/pages/stakes/amount-step.tsx new file mode 100644 index 000000000..3b13f19dc --- /dev/null +++ b/src/apps/popup/pages/stakes/amount-step.tsx @@ -0,0 +1,143 @@ +import React, { useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { UseFormReturn, useWatch } from 'react-hook-form'; +import { useSelector } from 'react-redux'; +import Big from 'big.js'; +import styled from 'styled-components'; + +import { + AlignedFlexRow, + ContentContainer, + ParagraphContainer, + SpacingSize, + VerticalSpaceContainer +} from '@libs/layout'; +import { Error, Input, Typography } from '@libs/ui'; +import { StakeAmountFormValues } from '@libs/ui/forms/stakes-form'; +import { formatFiatAmount, motesToCSPR } from '@libs/ui/utils/formatters'; +import { + selectAccountBalance, + selectAccountCurrencyRate +} from '@background/redux/account-info/selectors'; +import { AuctionManagerEntryPoint, STAKE_COST_MOTES } from '@src/constants'; + +const StakeMaxButton = styled(AlignedFlexRow)` + cursor: pointer; +`; + +interface AmountStepProps { + amountForm: UseFormReturn; + stakesType: AuctionManagerEntryPoint; + stakeAmountMotes: string; + headerText: string; + amountStepText: string; + amountStepMaxAmountValue: string | null; +} + +export const AmountStep = ({ + amountForm, + stakesType, + stakeAmountMotes, + headerText, + amountStepText, + amountStepMaxAmountValue +}: AmountStepProps) => { + const [maxAmountMotes, setMaxAmountMotes] = useState('0'); + + const { t } = useTranslation(); + + const currencyRate = useSelector(selectAccountCurrencyRate); + const csprBalance = useSelector(selectAccountBalance); + + useEffect(() => { + switch (stakesType) { + case AuctionManagerEntryPoint.delegate: { + const maxAmountMotes: string = + csprBalance.amountMotes == null + ? '0' + : Big(csprBalance.amountMotes).sub(STAKE_COST_MOTES).toString(); + + setMaxAmountMotes(maxAmountMotes); + break; + } + case AuctionManagerEntryPoint.undelegate: { + setMaxAmountMotes(stakeAmountMotes); + } + } + }, [csprBalance.amountMotes, stakeAmountMotes, stakesType]); + + const { + register, + formState: { errors }, + control, + setValue, + trigger + } = amountForm; + + const { onChange: onChangeCSPRAmount } = register('amount'); + + const amount = useWatch({ + control, + name: 'amount' + }); + + const amountLabel = t('Amount'); + + const fiatAmount = formatFiatAmount(amount || '0', currencyRate); + + return ( + + + + {headerText} + + + + + { + // replace all non-numeric characters except decimal point + e.target.value = e.target.value.replace(/[^0-9.]/g, ''); + // regex replace decimal point from beginning of string + e.target.value = e.target.value.replace(/^\./, ''); + onChangeCSPRAmount(e); + }} + /> + + + { + setValue('amount', motesToCSPR(maxAmountMotes)); + trigger('amount'); + }} + > + + {amountStepText} + + {amountStepMaxAmountValue && ( + + {amountStepMaxAmountValue} + + )} + + + {errors.amount && ( + + + + )} + + ); +}; diff --git a/src/apps/popup/pages/stakes/confirm-step.tsx b/src/apps/popup/pages/stakes/confirm-step.tsx new file mode 100644 index 000000000..0d2f2afd5 --- /dev/null +++ b/src/apps/popup/pages/stakes/confirm-step.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import styled from 'styled-components'; +import Big from 'big.js'; +import { useSelector } from 'react-redux'; + +import { + ContentContainer, + ParagraphContainer, + SpaceBetweenFlexRow, + SpacingSize, + VerticalSpaceContainer +} from '@libs/layout'; +import { AmountContainer, List, Typography, ValidatorPlate } from '@libs/ui'; +import { + formatFiatAmount, + formatNumber, + motesToCSPR +} from '@libs/ui/utils/formatters'; +import { selectAccountCurrencyRate } from '@background/redux/account-info/selectors'; +import { ValidatorResult } from '@libs/services/validators-service/types'; +import { getAuctionManagerDeployCost } from '@libs/services/deployer-service'; +import { AuctionManagerEntryPoint } from '@src/constants'; + +export const ListItemContainer = styled(SpaceBetweenFlexRow)` + padding: 12px 16px; +`; + +interface ConfirmStepProps { + inputAmountCSPR: string; + validator: ValidatorResult | null; + stakesType: AuctionManagerEntryPoint; + headerText: string; + confirmStepText: string; +} +export const ConfirmStep = ({ + inputAmountCSPR, + validator, + stakesType, + headerText, + confirmStepText +}: ConfirmStepProps) => { + const { t } = useTranslation(); + + const currencyRate = useSelector(selectAccountCurrencyRate); + + const transferFeeMotes = getAuctionManagerDeployCost(stakesType); + + const transferCostInCSPR = formatNumber(motesToCSPR(transferFeeMotes), { + precision: { max: 5 } + }); + const totalCSPR: string = Big(inputAmountCSPR) + .add(transferCostInCSPR) + .toString(); + + const transactionDataRows = [ + { + id: 1, + text: confirmStepText, + amount: formatNumber(inputAmountCSPR, { + precision: { max: 5 } + }), + fiatPrice: formatFiatAmount(inputAmountCSPR, currencyRate), + symbol: 'CSPR' + }, + { + id: 2, + text: t('Transaction fee'), + amount: transferCostInCSPR, + fiatPrice: formatFiatAmount(transferCostInCSPR, currencyRate, 3), + symbol: 'CSPR' + }, + { + id: 3, + text: t('Total'), + amount: formatNumber(totalCSPR, { + precision: { max: 5 } + }), + fiatPrice: formatFiatAmount(totalCSPR, currencyRate), + symbol: 'CSPR', + bold: true + } + ]; + + const validatorLabel = t('To validator'); + + if (!validator) { + return null; + } + + return ( + + + + {headerText} + + + + + + + {t('Amount and fee')} + + ( + + + {listItems.text} + + + {`${listItems.amount} ${listItems.symbol}`} + + {listItems.fiatPrice == null + ? null + : listItems.fiatPrice || 'Not available'} + + + + )} + marginLeftForItemSeparatorLine={8} + /> + + ); +}; diff --git a/src/apps/popup/pages/stakes/content.tsx b/src/apps/popup/pages/stakes/content.tsx new file mode 100644 index 000000000..0d9b0a57b --- /dev/null +++ b/src/apps/popup/pages/stakes/content.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { UseFormReturn } from 'react-hook-form'; + +import { ValidatorStep } from '@popup/pages/stakes/validator-step'; +import { AmountStep } from '@popup/pages/stakes/amount-step'; +import { + StakeAmountFormValues, + StakeValidatorFormValues +} from '@libs/ui/forms/stakes-form'; +import { ConfirmStep } from '@popup/pages/stakes/confirm-step'; +import { TransferSuccessScreen, ValidatorDropdownInput } from '@libs/ui'; +import { AuctionManagerEntryPoint, StakeSteps } from '@src/constants'; +import { ValidatorResultWithId } from '@libs/services/validators-service/types'; +import { formatNumber, motesToCSPR } from '@libs/ui/utils/formatters'; + +interface DelegateStakePageContentProps { + stakeStep: StakeSteps; + validatorForm: UseFormReturn; + amountForm: UseFormReturn; + inputAmountCSPR: string; + validator: ValidatorResultWithId | null; + setValidator: React.Dispatch< + React.SetStateAction + >; + stakesType: AuctionManagerEntryPoint; + stakeAmountMotes: string; + setStakeAmount: React.Dispatch>; + validatorList: ValidatorResultWithId[] | null; +} + +export const StakesPageContent = ({ + stakeStep, + validatorForm, + amountForm, + inputAmountCSPR, + validator, + setValidator, + stakesType, + stakeAmountMotes, + setStakeAmount, + validatorList +}: DelegateStakePageContentProps) => { + const [validateStepHeaderText, setValidateStepHeaderText] = useState(''); + const [amountStepHeaderText, setAmountStepHeaderText] = useState(''); + const [confirmStepHeaderText, setConfirmStepHeaderText] = useState(''); + const [successStepHeaderText, setSuccessStepHeaderText] = useState(''); + const [confirmStepText, setConfirmStepText] = useState(''); + const [amountStepText, setAmountStepText] = useState(''); + const [amountStepMaxAmountValue, setAmountStepMaxAmountValue] = useState< + string | null + >(null); + + useEffect(() => { + const formattedAmountCSPR = + stakeAmountMotes && + formatNumber(motesToCSPR(stakeAmountMotes), { precision: { max: 4 } }); + + switch (stakesType) { + case AuctionManagerEntryPoint.delegate: { + setValidateStepHeaderText('Delegate'); + setAmountStepHeaderText('Delegate amount'); + setConfirmStepHeaderText('Confirm delegation'); + setSuccessStepHeaderText('You’ve submitted a delegation'); + + setAmountStepText('Delegate max'); + setConfirmStepText('You’ll delegate'); + break; + } + case AuctionManagerEntryPoint.undelegate: { + setValidateStepHeaderText('Undelegate'); + setAmountStepHeaderText('Undelegate amount'); + setConfirmStepHeaderText('Confirm undelegation'); + setSuccessStepHeaderText('You’ve submitted an undelegation'); + + setAmountStepText('Undelegate max:'); + setAmountStepMaxAmountValue(`${formattedAmountCSPR} CSPR`); + setConfirmStepText('You’ll undelegate'); + break; + } + + default: + throw Error('fetch validator: unknown stakes type'); + } + }, [stakeAmountMotes, stakesType]); + + switch (stakeStep) { + case StakeSteps.Validator: { + return ( + + + + ); + } + case StakeSteps.Amount: { + return ( + + ); + } + case StakeSteps.Confirm: { + return ( + + ); + } + case StakeSteps.Success: { + return ; + } + default: { + throw Error('Out of bound: StakeSteps'); + } + } +}; diff --git a/src/apps/popup/pages/stakes/index.tsx b/src/apps/popup/pages/stakes/index.tsx new file mode 100644 index 000000000..0eaae0a29 --- /dev/null +++ b/src/apps/popup/pages/stakes/index.tsx @@ -0,0 +1,412 @@ +import React, { useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { + CenteredFlexRow, + ContentContainer, + FooterButtonsContainer, + HeaderSubmenuBarNavLink, + ParagraphContainer, + PopupHeader, + PopupLayout, + SpaceBetweenFlexRow, + SpacingSize +} from '@libs/layout'; +import { StakesPageContent } from '@popup/pages/stakes/content'; +import { Button, HomePageTabsId, Typography } from '@libs/ui'; +import { selectVaultActiveAccount } from '@background/redux/vault/selectors'; +import { createAsymmetricKey } from '@libs/crypto/create-asymmetric-key'; +import { selectApiConfigBasedOnActiveNetwork } from '@background/redux/settings/selectors'; +import { + AuctionManagerEntryPoint, + STAKE_COST_MOTES, + StakeSteps +} from '@src/constants'; +import { dispatchToMainStore } from '@background/redux/utils'; +import { accountPendingTransactionsChanged } from '@background/redux/account-info/actions'; +import { dispatchFetchExtendedDeploysInfo } from '@libs/services/account-activity-service'; +import { makeAuctionManagerDeploy } from '@libs/services/deployer-service'; +import { RouterPath, useTypedLocation, useTypedNavigate } from '@popup/router'; +import { useStakesForm } from '@libs/ui/forms/stakes-form'; +import { selectAccountBalance } from '@background/redux/account-info/selectors'; +import { calculateSubmitButtonDisabled } from '@libs/ui/forms/get-submit-button-state-from-validation'; +import { + CSPRtoMotes, + formatNumber, + motesToCSPR +} from '@libs/ui/utils/formatters'; +import { ValidatorResultWithId } from '@libs/services/validators-service/types'; +import { + dispatchFetchAuctionValidatorsRequest, + dispatchFetchValidatorsDetailsDataRequest +} from '@libs/services/validators-service'; +import { NoDelegations } from '@popup/pages/stakes/no-delegations'; + +export const StakesPage = () => { + const [stakeStep, setStakeStep] = useState(StakeSteps.Validator); + const [validatorPublicKey, setValidatorPublicKey] = useState(''); + const [inputAmountCSPR, setInputAmountCSPR] = useState(''); + const [isSubmitButtonDisable, setIsSubmitButtonDisable] = useState(true); + const [validator, setValidator] = useState( + null + ); + const [stakesType, setStakesType] = useState( + AuctionManagerEntryPoint.delegate + ); + const [stakeAmountMotes, setStakeAmountMotes] = useState(''); + const [validatorList, setValidatorList] = useState< + ValidatorResultWithId[] | null + >(null); + const [loading, setLoading] = useState(true); + + const activeAccount = useSelector(selectVaultActiveAccount); + const { networkName, nodeUrl, auctionManagerContractHash, casperApiUrl } = + useSelector(selectApiConfigBasedOnActiveNetwork); + const csprBalance = useSelector(selectAccountBalance); + + const { t } = useTranslation(); + const navigate = useTypedNavigate(); + const { pathname } = useTypedLocation(); + + useEffect(() => { + // checking pathname to know what type of stake it is + if (pathname.split('/')[1] === AuctionManagerEntryPoint.delegate) { + setStakesType(AuctionManagerEntryPoint.delegate); + + dispatchFetchAuctionValidatorsRequest() + .then(({ payload }) => { + if ('data' in payload) { + const { data } = payload; + + const validatorListWithId = data.map(validator => ({ + ...validator, + id: validator.public_key + })); + + setValidatorList(validatorListWithId); + } + }) + .finally(() => { + setLoading(false); + }); + } else if (pathname.split('/')[1] === AuctionManagerEntryPoint.undelegate) { + setStakesType(AuctionManagerEntryPoint.undelegate); + + if (activeAccount) { + dispatchFetchValidatorsDetailsDataRequest(activeAccount.publicKey) + .then(({ payload }) => { + if ('data' in payload) { + const { data } = payload; + + const validatorListWithId = data.map(delegator => ({ + ...delegator.validator, + id: delegator.validator_public_key, + user_stake: delegator.stake + })); + + setValidatorList(validatorListWithId); + } + }) + .finally(() => { + setLoading(false); + }); + } + } + }, [activeAccount, pathname, casperApiUrl]); + + const { amountForm, validatorForm } = useStakesForm( + csprBalance.amountMotes, + stakesType, + stakeAmountMotes, + validator?.delegators_number + ); + const { formState: amountFormState, getValues: getValuesAmountForm } = + amountForm; + const { formState: validatorFormState, getValues: getValuesValidatorForm } = + validatorForm; + + // event listener for enable/disable submit button + useEffect(() => { + if (stakeStep !== StakeSteps.Confirm) return; + + const layoutContentContainer = document.querySelector('#ms-container'); + + // if the content is not scrollable, we can enable the submit button + if ( + layoutContentContainer && + layoutContentContainer.clientHeight === + layoutContentContainer.scrollHeight && + isSubmitButtonDisable + ) { + setIsSubmitButtonDisable(false); + } + + const handleScroll = () => { + if (layoutContentContainer && isSubmitButtonDisable) { + const bottom = + Math.ceil( + layoutContentContainer.clientHeight + + layoutContentContainer.scrollTop + ) >= layoutContentContainer.scrollHeight; + + if (bottom) { + setIsSubmitButtonDisable(false); + } + } + }; + + // add event listener to the scrollable container + layoutContentContainer?.addEventListener('scroll', handleScroll); + + // remove event listener on cleanup + return () => { + layoutContentContainer?.removeEventListener('scroll', handleScroll); + }; + }, [isSubmitButtonDisable, stakeStep]); + + const submitStake = () => { + if (activeAccount) { + const motesAmount = CSPRtoMotes(inputAmountCSPR); + + const KEYS = createAsymmetricKey( + activeAccount.publicKey, + activeAccount.secretKey + ); + + const deploy = makeAuctionManagerDeploy( + stakesType, + activeAccount.publicKey, + validatorPublicKey, + null, + motesAmount, + networkName, + auctionManagerContractHash + ); + + const signDeploy = deploy.sign([KEYS]); + + signDeploy.send(nodeUrl).then((deployHash: string) => { + if (deployHash) { + let triesLeft = 10; + const interval = setInterval(async () => { + const { payload: extendedDeployInfo } = + await dispatchFetchExtendedDeploysInfo(deployHash); + if (extendedDeployInfo) { + dispatchToMainStore( + accountPendingTransactionsChanged(extendedDeployInfo) + ); + clearInterval(interval); + } else if (triesLeft === 0) { + clearInterval(interval); + } + + triesLeft--; + // Note: this timeout is needed because the deploy is not immediately visible in the explorer + }, 2000); + } + }); + // TODO: need UI in case when the delegation request is failed + setStakeStep(StakeSteps.Success); + } + }; + + const getButtonProps = () => { + const isValidatorFormButtonDisabled = calculateSubmitButtonDisabled({ + isValid: validatorFormState.isValid + }); + const isAmountFormButtonDisabled = calculateSubmitButtonDisabled({ + isValid: amountFormState.isValid + }); + + switch (stakeStep) { + case StakeSteps.Validator: { + return { + disabled: isValidatorFormButtonDisabled, + onClick: () => { + const { validatorPublicKey } = getValuesValidatorForm(); + + setStakeStep(StakeSteps.Amount); + setValidatorPublicKey(validatorPublicKey); + } + }; + } + case StakeSteps.Amount: { + return { + disabled: isAmountFormButtonDisabled, + onClick: () => { + const { amount: _amount } = getValuesAmountForm(); + + setInputAmountCSPR(_amount); + setStakeStep(StakeSteps.Confirm); + } + }; + } + case StakeSteps.Confirm: { + return { + disabled: + isSubmitButtonDisable || + isValidatorFormButtonDisabled || + isAmountFormButtonDisabled, + onClick: submitStake + }; + } + case StakeSteps.Success: { + return { + onClick: () => { + navigate(RouterPath.Home, { + state: { + // set the active tab to deploys + activeTabId: HomePageTabsId.Deploys + } + }); + } + }; + } + } + }; + + const handleBackButton = () => { + switch (stakeStep) { + case StakeSteps.Validator: { + navigate(-1); + break; + } + case StakeSteps.Amount: { + setStakeStep(StakeSteps.Validator); + break; + } + case StakeSteps.Confirm: { + setStakeStep(StakeSteps.Amount); + break; + } + + default: { + navigate(-1); + break; + } + } + }; + + const getConfirmButtonText = () => { + switch (stakesType) { + case AuctionManagerEntryPoint.delegate: { + return t('Confirm delegation'); + } + case AuctionManagerEntryPoint.undelegate: { + return t('Confirm undelegation'); + } + default: { + return t('Confirm'); + } + } + }; + + if (loading) { + return ( + ( + + )} + renderContent={() => ( + + + + Loading... + + + + )} + /> + ); + } + + if ( + stakesType === AuctionManagerEntryPoint.undelegate && + validatorList !== null && + validatorList.length === 0 + ) { + return ( + ( + + )} + renderContent={() => } + renderFooter={() => ( + + + + )} + /> + ); + } + + return ( + ( + ( + + ) + } + /> + )} + renderContent={() => ( + + )} + renderFooter={() => ( + + {stakeStep === StakeSteps.Amount ? ( + + + Transaction fee + + + {formatNumber(motesToCSPR(STAKE_COST_MOTES), { + precision: { max: 5 } + })}{' '} + CSPR + + + ) : null} + + + )} + /> + ); +}; diff --git a/src/apps/popup/pages/stakes/no-delegations.tsx b/src/apps/popup/pages/stakes/no-delegations.tsx new file mode 100644 index 000000000..1d74464e3 --- /dev/null +++ b/src/apps/popup/pages/stakes/no-delegations.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { + ContentContainer, + ParagraphContainer, + SpacingSize +} from '@libs/layout'; +import { SvgIcon, Typography } from '@libs/ui'; + +export const NoDelegations = () => { + const { t } = useTranslation(); + + return ( + + + + + + + You haven’t delegated with this account yet + + + + + + You can only undelegate if you’ve delegated from this account + before. + + + + + ); +}; diff --git a/src/apps/popup/pages/stakes/validator-step.tsx b/src/apps/popup/pages/stakes/validator-step.tsx new file mode 100644 index 000000000..c6cae9dc9 --- /dev/null +++ b/src/apps/popup/pages/stakes/validator-step.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { + ContentContainer, + ParagraphContainer, + SpacingSize +} from '@libs/layout'; +import { Typography } from '@libs/ui'; + +interface ValidatorStepProps { + children?: React.ReactNode; + headerText: string; +} +export const ValidatorStep = ({ headerText, children }: ValidatorStepProps) => { + const { t } = useTranslation(); + + return ( + + + + {headerText} + + + + {children} + + ); +}; diff --git a/src/apps/popup/pages/transfer-nft/index.tsx b/src/apps/popup/pages/transfer-nft/index.tsx index 75b55e3bc..4304bef96 100644 --- a/src/apps/popup/pages/transfer-nft/index.tsx +++ b/src/apps/popup/pages/transfer-nft/index.tsx @@ -176,7 +176,7 @@ export const TransferNftPage = () => { )} renderContent={() => showSuccessScreen ? ( - + ) : ( ; + return ; } default: { diff --git a/src/apps/popup/pages/transfer/index.tsx b/src/apps/popup/pages/transfer/index.tsx index 4f4d7e6ef..306ff1a3d 100644 --- a/src/apps/popup/pages/transfer/index.tsx +++ b/src/apps/popup/pages/transfer/index.tsx @@ -117,9 +117,7 @@ export const TransferPage = () => { // event listener for enable/disable submit button useEffect(() => { - const layoutContentContainer = document.querySelector( - '#layout-content-container' - ); + const layoutContentContainer = document.querySelector('#ms-container'); // if the content is not scrollable, we can enable the submit button if ( diff --git a/src/apps/popup/router/paths.ts b/src/apps/popup/router/paths.ts index cc2e6eba6..3922bc54c 100644 --- a/src/apps/popup/router/paths.ts +++ b/src/apps/popup/router/paths.ts @@ -22,5 +22,7 @@ export enum RouterPath { NftDetails = '/nft-details/:contractPackageHash/nfts/:tokenId', GenerateWalletQRCode = '/generate-wallet-qr-code', TransferNft = '/transfer-nft/:contractPackageHash/nfts/:tokenId', - ChangePassword = '/change-password' + ChangePassword = '/change-password', + Delegate = '/delegate', + Undelegate = '/undelegate' } diff --git a/src/apps/popup/router/types.ts b/src/apps/popup/router/types.ts index 69c14e73c..1156cf0f5 100644 --- a/src/apps/popup/router/types.ts +++ b/src/apps/popup/router/types.ts @@ -1,4 +1,4 @@ -import { TransferType } from '@src/constants'; +import { ActivityType } from '@src/constants'; import { TokenType } from '@src/hooks'; export type LocationState = { @@ -7,7 +7,7 @@ export type LocationState = { fromAccount: string; toAccount: string; deployHash: string; - type: TransferType | null; + type: ActivityType | null; amount?: string; symbol?: string; isDeploysList?: boolean; diff --git a/src/assets/icons/burn.svg b/src/assets/icons/burn.svg new file mode 100644 index 000000000..be330d02b --- /dev/null +++ b/src/assets/icons/burn.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/delegate.svg b/src/assets/icons/delegate.svg new file mode 100644 index 000000000..87128f7de --- /dev/null +++ b/src/assets/icons/delegate.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/empty-state.svg b/src/assets/icons/empty-state.svg new file mode 100644 index 000000000..9cb85889b --- /dev/null +++ b/src/assets/icons/empty-state.svg @@ -0,0 +1,739 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/undelegate.svg b/src/assets/icons/undelegate.svg new file mode 100644 index 000000000..9904c3ace --- /dev/null +++ b/src/assets/icons/undelegate.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/background/index.ts b/src/background/index.ts index c724a0560..5c7fc75a6 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -132,6 +132,10 @@ import { } from '@background/redux/account-info/actions'; import { fetchErc20TokenActivity } from '@src/libs/services/account-activity-service/erc20-token-activity-service'; import { fetchNftTokens } from '@libs/services/nft-service'; +import { + fetchAuctionValidators, + fetchValidatorsDetailsData +} from '@libs/services/validators-service'; // setup default onboarding action async function handleActionClick() { @@ -748,6 +752,45 @@ browser.runtime.onMessage.addListener( return; } + case getType(serviceMessage.fetchAuctionValidatorsRequest): { + const { casperApiUrl } = selectApiConfigBasedOnActiveNetwork( + store.getState() + ); + + try { + const data = await fetchAuctionValidators({ casperApiUrl }); + + return sendResponse( + serviceMessage.fetchAuctionValidatorsResponse(data) + ); + } catch (error) { + console.error(error); + } + + return; + } + + case getType(serviceMessage.fetchValidatorsDetailsDataRequest): { + const { casperApiUrl } = selectApiConfigBasedOnActiveNetwork( + store.getState() + ); + + try { + const data = await fetchValidatorsDetailsData({ + casperApiUrl, + publicKey: action.payload.publicKey + }); + + return sendResponse( + serviceMessage.fetchValidatorsDetailsDataResponse(data) + ); + } catch (error) { + console.error(error); + } + + return; + } + // TODO: All below should be removed when Import Account is integrated with window case 'check-secret-key-exist' as any: { const { secretKeyBase64 } = ( diff --git a/src/background/redux/settings/selectors.ts b/src/background/redux/settings/selectors.ts index 62d11254a..4b9cf50d9 100644 --- a/src/background/redux/settings/selectors.ts +++ b/src/background/redux/settings/selectors.ts @@ -2,6 +2,7 @@ import { RootState } from 'typesafe-actions'; import { createSelector } from 'reselect'; import { + AuctionManagerContractHash, CasperApiUrl, CasperLiveUrl, CasperNodeUrl, @@ -24,14 +25,16 @@ export const selectApiConfigBasedOnActiveNetwork = createSelector( casperLiveUrl: CasperLiveUrl.MainnetUrl, casperApiUrl: CasperApiUrl.MainnetUrl, networkName: NetworkName.Mainnet, - nodeUrl: CasperNodeUrl.MainnetUrl + nodeUrl: CasperNodeUrl.MainnetUrl, + auctionManagerContractHash: AuctionManagerContractHash.Mainnet }; case NetworkSetting.Testnet: return { casperLiveUrl: CasperLiveUrl.TestnetUrl, casperApiUrl: CasperApiUrl.TestnetUrl, networkName: NetworkName.Testnet, - nodeUrl: CasperNodeUrl.TestnetUrl + nodeUrl: CasperNodeUrl.TestnetUrl, + auctionManagerContractHash: AuctionManagerContractHash.Testnet }; default: throw new Error(`Unknown network: ${activeNetwork}`); diff --git a/src/background/service-message.ts b/src/background/service-message.ts index 8cffd7e6d..2885da328 100644 --- a/src/background/service-message.ts +++ b/src/background/service-message.ts @@ -10,6 +10,10 @@ import { import { ErrorResponse, PaginatedResponse } from '@libs/services/types'; import { ContractPackageWithBalance } from '@libs/services/erc20-service'; import { NFTTokenResult } from '@libs/services/nft-service'; +import { + DelegatorResult, + ValidatorResult +} from '@libs/services/validators-service'; type Meta = void; @@ -80,7 +84,19 @@ export const serviceMessage = { fetchNftTokensResponse: createAction('FETCH_NFT_TOKENS_RESPONSE')< PaginatedResponse | ErrorResponse, Meta - >() + >(), + fetchAuctionValidatorsRequest: createAction( + 'FETCH_AUCTION_VALIDATORS' + )(), + fetchAuctionValidatorsResponse: createAction( + 'FETCH_AUCTION_VALIDATORS_RESPONSE' + ) | ErrorResponse, Meta>(), + fetchValidatorsDetailsDataRequest: createAction( + 'FETCH_VALIDATORS_DETAILS_DATA' + )<{ publicKey: string }, Meta>(), + fetchValidatorsDetailsDataResponse: createAction( + 'FETCH_VALIDATORS_DETAILS_DATA_RESPONSE' + ) | ErrorResponse, Meta>() }; export type ServiceMessage = ActionType; diff --git a/src/constants.ts b/src/constants.ts index 0ca8ec244..c53c13976 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,6 +12,7 @@ export const NFT_TOKENS_REFRESH_RATE = 60 * SECOND; export const ACCOUNT_DEPLOY_REFRESH_RATE = 30 * SECOND; export const ACCOUNT_CASPER_ACTIVITY_REFRESH_RATE = 30 * SECOND; export const ERC20_TOKEN_ACTIVITY_REFRESH_RATE = 30 * SECOND; +export const VALIDATORS_REFRESH_RATE = 30 * SECOND; export const LOGIN_RETRY_ATTEMPTS_LIMIT = 5; @@ -20,6 +21,9 @@ export const TRANSFER_MIN_AMOUNT_MOTES = '2500000000'; // 2.5 CSPR export const ERC20_PAYMENT_AMOUNT_AVERAGE_MOTES = '1500000000'; // 1.5 CSPR export const NFT_CEP47_PAYMENT_AMOUNT_AVERAGE_MOTES = '1000000000'; // 1 CSPR export const NFT_CEP78_PAYMENT_AMOUNT_AVERAGE_MOTES = '3000000000'; // 3 CSPR +export const STAKE_COST_MOTES = '2500000000'; // 2.5 CSPR +export const DELEGATION_MIN_AMOUNT_MOTES = '500000000000'; // 500 CSPR +export const MAX_DELEGATORS = 1200; export const getBlockExplorerAccountUrl = (baseUrl: string, hash: string) => `${baseUrl}/account/${hash}`; @@ -77,34 +81,64 @@ export enum NetworkName { Testnet = 'casper-test' } -export enum TransferType { +export enum AuctionManagerContractHash { + Mainnet = 'ccb576d6ce6dec84a551e48f0d0b7af89ddba44c7390b690036257a04a3ae9ea', + Testnet = '93d923e336b20a4c4ca14d592b60e5bd3fe330775618290104f9beb326db7ae2' +} + +export enum ActivityType { Sent = 'Sent', Received = 'Received', - Unknown = 'Unknown' + Unknown = 'Unknown', + Delegated = 'Delegated', + Undelegated = 'Undelegated', + Redelegated = 'Redelegated', + Mint = 'Mint', + Burn = 'Burn' } -export const ShortTypeName = { - [TransferType.Sent]: 'Sent', - [TransferType.Received]: 'Recv', - [TransferType.Unknown]: 'Unk' +export const ActivityShortTypeName = { + [ActivityType.Sent]: 'Sent', + [ActivityType.Received]: 'Recv', + [ActivityType.Unknown]: 'Unk', + [ActivityType.Delegated]: 'Deleg', + [ActivityType.Undelegated]: 'Undeleg', + [ActivityType.Redelegated]: 'Redeleg', + [ActivityType.Mint]: 'Mint', + [ActivityType.Burn]: 'Burn' }; -export const TypeName = { - [TransferType.Sent]: 'Sent', - [TransferType.Received]: 'Received', - [TransferType.Unknown]: 'Unknown' +export const ActivityTypeName = { + [ActivityType.Sent]: 'Sent', + [ActivityType.Received]: 'Received', + [ActivityType.Unknown]: 'Unknown', + [ActivityType.Delegated]: 'Delegated', + [ActivityType.Undelegated]: 'Undelegated', + [ActivityType.Redelegated]: 'Redelegated', + [ActivityType.Mint]: 'Mint', + [ActivityType.Burn]: 'Burn' }; -export const TypeIcons = { - [TransferType.Sent]: 'assets/icons/transfer.svg', - [TransferType.Received]: 'assets/icons/receive.svg', - [TransferType.Unknown]: 'assets/icons/info.svg' +export const ActivityTypeIcons = { + [ActivityType.Sent]: 'assets/icons/transfer.svg', + [ActivityType.Received]: 'assets/icons/receive.svg', + [ActivityType.Unknown]: 'assets/icons/info.svg', + [ActivityType.Delegated]: 'assets/icons/delegate.svg', + [ActivityType.Undelegated]: 'assets/icons/undelegate.svg', + [ActivityType.Redelegated]: 'assets/icons/undelegate.svg', + [ActivityType.Mint]: 'assets/icons/info.svg', + [ActivityType.Burn]: 'assets/icons/burn.svg' }; -export const TypeColors = { - [TransferType.Sent]: 'contentAction', - [TransferType.Received]: 'contentPositive', - [TransferType.Unknown]: 'contentDisabled' +export const ActivityTypeColors = { + [ActivityType.Sent]: 'contentAction', + [ActivityType.Received]: 'contentPositive', + [ActivityType.Unknown]: 'contentDisabled', + [ActivityType.Delegated]: 'contentAction', + [ActivityType.Undelegated]: 'contentAction', + [ActivityType.Redelegated]: 'contentAction', + [ActivityType.Mint]: 'contentDisabled', + [ActivityType.Burn]: 'contentAction' }; export enum HomePageTabName { @@ -112,3 +146,21 @@ export enum HomePageTabName { Deploys = 'Deploys', NFTs = 'NFTs' } + +export enum StakeSteps { + Validator = 'validator', + Amount = 'amount', + Confirm = 'confirm', + Success = 'success' +} + +export enum AuctionManagerEntryPoint { + delegate = 'delegate', + undelegate = 'undelegate', + redelegate = 'redelegate' +} + +export enum TokenEntryPoint { + mint = 'mint', + burn = 'burn' +} diff --git a/src/libs/layout/header/header-connection-status.tsx b/src/libs/layout/header/header-connection-status.tsx index 9afefabb1..3e0306353 100644 --- a/src/libs/layout/header/header-connection-status.tsx +++ b/src/libs/layout/header/header-connection-status.tsx @@ -23,6 +23,7 @@ export function HeaderConnectionStatus() { return ( ( )} diff --git a/src/libs/layout/header/header-network-switcher.tsx b/src/libs/layout/header/header-network-switcher.tsx index 79c7b3dc7..9c941d580 100644 --- a/src/libs/layout/header/header-network-switcher.tsx +++ b/src/libs/layout/header/header-network-switcher.tsx @@ -50,6 +50,7 @@ export const HeaderNetworkSwitcher = () => { return ( ( void; + backTypeWithBalance?: boolean; } export function HeaderSubmenuBarNavLink({ linkType, - onClick + onClick, + backTypeWithBalance }: HeaderSubmenuBarNavLinkProps) { const { t } = useTranslation(); const navigate = useTypedNavigate(); + const balance = useSelector(selectAccountBalance); + + const formattedBalance = formatNumber( + (balance.amountMotes && motesToCSPR(balance.amountMotes)) || '' + ); + switch (linkType) { case 'close': return ( @@ -52,7 +64,29 @@ export function HeaderSubmenuBarNavLink({ ); case 'back': - return ( + return backTypeWithBalance ? ( + <> + { + if (onClick) { + onClick(); + } else { + navigate(-1); + } + }} + withLeftChevronIcon + /> + + + Balance: + + + {`${formattedBalance} CSPR`} + + + + ) : ( { diff --git a/src/libs/services/account-activity-service/types.ts b/src/libs/services/account-activity-service/types.ts index 726bb1719..8ff28f6db 100644 --- a/src/libs/services/account-activity-service/types.ts +++ b/src/libs/services/account-activity-service/types.ts @@ -52,6 +52,7 @@ export type ExtendedDeployArgsResult = { to?: ExtendedDeployClTypeResult; validator?: ExtendedDeployClTypeResult; new_validator?: ExtendedDeployClTypeResult; + delegator?: ExtendedDeployClTypeResult; }; export interface ExtendedDeployResult { diff --git a/src/libs/services/deployer-service/index.ts b/src/libs/services/deployer-service/index.ts index 6f77f274d..5e7f648fe 100644 --- a/src/libs/services/deployer-service/index.ts +++ b/src/libs/services/deployer-service/index.ts @@ -1,11 +1,35 @@ -import { CasperServiceByJsonRPC, CLPublicKey, DeployUtil } from 'casper-js-sdk'; +import { + CasperServiceByJsonRPC, + CLPublicKey, + CLValueBuilder, + decodeBase16, + DeployUtil, + RuntimeArgs +} from 'casper-js-sdk'; +import { sub } from 'date-fns'; import { signDeploy } from '@libs/crypto'; +import { getRawPublicKey } from '@libs/entities/Account'; +import { AuctionManagerEntryPoint, STAKE_COST_MOTES } from '@src/constants'; import { RPCResponse } from './types'; const casperService = (url: string) => new CasperServiceByJsonRPC(url); +export const getAuctionManagerDeployCost = ( + entryPoint: AuctionManagerEntryPoint +) => { + switch (entryPoint) { + case AuctionManagerEntryPoint.delegate: + case AuctionManagerEntryPoint.undelegate: + case AuctionManagerEntryPoint.redelegate: + return STAKE_COST_MOTES; + + default: + throw Error('getAuctionManagerDeployCost: unknown entry point'); + } +}; + export const signAndDeploy = ( deploy: DeployUtil.Deploy, senderPublicKeyHex: string, @@ -33,3 +57,51 @@ export const signAndDeploy = ( throw error; }); }; + +export const makeAuctionManagerDeploy = ( + contractEntryPoint: AuctionManagerEntryPoint, + delegatorPublicKeyHex: string, + validatorPublicKeyHex: string, + redelegateValidatorPublicKeyHex: string | null, + amountMotes: string, + networkName: string, + auctionManagerContractHash: string +) => { + const hash = decodeBase16(auctionManagerContractHash); + + const delegatorPublicKey = getRawPublicKey(delegatorPublicKeyHex); + const validatorPublicKey = getRawPublicKey(validatorPublicKeyHex); + const newValidatorPublicKey = + redelegateValidatorPublicKeyHex && + getRawPublicKey(redelegateValidatorPublicKeyHex); + + const runtimeArgs = RuntimeArgs.fromMap({ + validator: validatorPublicKey, + delegator: delegatorPublicKey, + amount: CLValueBuilder.u512(amountMotes), + ...(newValidatorPublicKey && { + new_validator: newValidatorPublicKey + }) + }); + + const deployParams = new DeployUtil.DeployParams( + delegatorPublicKey, + networkName, + undefined, + undefined, + undefined, + sub(new Date(), { seconds: 2 }).getTime() + ); // https://github.com/casper-network/casper-node/issues/4152 + + const session = DeployUtil.ExecutableDeployItem.newStoredContractByHash( + hash, + contractEntryPoint, + runtimeArgs + ); + + const deployCost = getAuctionManagerDeployCost(contractEntryPoint); + + const payment = DeployUtil.standardPayment(deployCost); + + return DeployUtil.makeDeploy(deployParams, session, payment); +}; diff --git a/src/libs/services/validators-service/constants.ts b/src/libs/services/validators-service/constants.ts new file mode 100644 index 000000000..9a2b4e886 --- /dev/null +++ b/src/libs/services/validators-service/constants.ts @@ -0,0 +1,9 @@ +export const getAuctionValidatorsUrl = (casperApiUrl: string) => + `${casperApiUrl}/auction-validators?page=1&limit=-1&fields=account_info,average_performance&is_active=true`; + +export const getValidatorsDetailsDataUrl = ( + casperApiUrl: string, + publicKey: string +) => ` + ${casperApiUrl}/accounts/${publicKey}/delegations?page=1&limit=100&fields=validator,validator_account_info +`; diff --git a/src/libs/services/validators-service/index.ts b/src/libs/services/validators-service/index.ts new file mode 100644 index 000000000..963aa288f --- /dev/null +++ b/src/libs/services/validators-service/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './validators-service'; +export * from './constants'; diff --git a/src/libs/services/validators-service/types.ts b/src/libs/services/validators-service/types.ts new file mode 100644 index 000000000..486a4c5b0 --- /dev/null +++ b/src/libs/services/validators-service/types.ts @@ -0,0 +1,124 @@ +export interface ValidatorResult { + fee: number; + is_active: boolean; + self_stake: string; + bid_amount: string; + total_stake: string; + self_share: string; + public_key: string; + network_share: string; + era_id: number; + delegators_number: number; + delegator_stake: string; + rank: number; + account_info?: AccountInfoResult; + average_performance?: ValidatorAveragePerformanceResult; +} + +export interface ValidatorResultWithId extends ValidatorResult { + id: string; + user_stake?: string; +} + +export interface ValidatorAveragePerformanceResult { + era_id: number; + public_key: string; + average_score: number; // between 0 and 100, treat it as percentage +} + +export interface AccountInfoResult { + account_hash: string; + url: string; + is_active: boolean; + deploy_hash: string; + verified_account_hashes: string[]; + info?: { + owner?: AccountInfoOwner; + nodes?: Array; + }; +} + +export interface AccountInfoOwner { + name?: string; + description?: string; + type?: Array; + email?: string; + identity?: { + ownership_disclosure_url?: string; + casper_association_kyc_url?: string; + casper_association_kyc_onchain?: string; + }; + resources?: { + code_of_conduct_url?: string; + terms_of_service_url?: string; + privacy_policy_url?: string; + other?: Array<{ + name?: string; + url?: string; + }>; + }; + affiliated_accounts?: Array; + website?: string; + branding?: { + logo?: { + svg?: string; + png_256?: string; + png_1024?: string; + }; + }; + location?: { + name?: string; + country?: string; + latitude?: number; + longitude?: number; + }; + social?: { + github?: string; + medium?: string; + reddit?: string; + wechat?: string; + keybase?: string; + twitter?: string; + youtube?: string; + facebook?: string; + telegram?: string; + }; +} + +export interface AccountInfoAffiliatedAccount { + public_key?: string; +} + +export interface AccountInfoNode { + public_key?: string; + description?: string; + functionality?: string[]; + location?: { + name?: string; + country?: string; + latitude?: number; + longitude?: number; + }; +} + +export interface DelegatorResult { + validator_public_key: string; + public_key: string; + stake: string; + bonding_purse: string; + account_info?: AccountInfoResult; + validator_account_info?: ValidatorAccountInfoResult; + validator: ValidatorResult; +} + +export interface ValidatorAccountInfoResult { + account_hash: string; + deploy_hash: string; + info: { + owner?: AccountInfoOwner; + nodes?: Array; + }; + is_active: boolean; + url: string; + verified_account_hashes: string[]; +} diff --git a/src/libs/services/validators-service/validators-service.ts b/src/libs/services/validators-service/validators-service.ts new file mode 100644 index 000000000..9ab6ee779 --- /dev/null +++ b/src/libs/services/validators-service/validators-service.ts @@ -0,0 +1,75 @@ +import { + getAuctionValidatorsUrl, + getValidatorsDetailsDataUrl +} from '@libs/services/validators-service/constants'; +import { handleError, toJson } from '@libs/services/utils'; +import { queryClient } from '@libs/services/query-client'; +import { dispatchToMainStore } from '@background/redux/utils'; +import { + ErrorResponse, + PaginatedResponse, + Payload +} from '@libs/services/types'; +import { + DelegatorResult, + ValidatorResult +} from '@libs/services/validators-service/types'; +import { serviceMessage } from '@background/service-message'; +import { VALIDATORS_REFRESH_RATE } from '@src/constants'; + +export const auctionValidatorsRequest = ( + casperApiUrl: string, + signal?: AbortSignal +) => + fetch(getAuctionValidatorsUrl(casperApiUrl), { signal }) + .then(toJson) + .catch(handleError); + +export const validatorsDetailsDataRequest = ( + casperApiUrl: string, + publicKey: string, + signal?: AbortSignal +) => + fetch(getValidatorsDetailsDataUrl(casperApiUrl, publicKey), { signal }) + .then(toJson) + .catch(handleError); + +export const fetchAuctionValidators = ({ + casperApiUrl +}: { + casperApiUrl: string; +}): Promise | ErrorResponse> => + queryClient.fetchQuery( + ['getAuctionValidators', casperApiUrl], + ({ signal }) => auctionValidatorsRequest(casperApiUrl, signal), + { + staleTime: VALIDATORS_REFRESH_RATE + } + ); + +export const fetchValidatorsDetailsData = ({ + casperApiUrl, + publicKey +}: { + casperApiUrl: string; + publicKey: string; +}): Promise | ErrorResponse> => + queryClient.fetchQuery( + ['getDelegations', casperApiUrl, publicKey], + ({ signal }) => + validatorsDetailsDataRequest(casperApiUrl, publicKey, signal), + { + staleTime: VALIDATORS_REFRESH_RATE + } + ); + +export const dispatchFetchAuctionValidatorsRequest = (): Promise< + Payload | ErrorResponse> +> => dispatchToMainStore(serviceMessage.fetchAuctionValidatorsRequest()); + +export const dispatchFetchValidatorsDetailsDataRequest = ( + publicKey: string +): Promise | ErrorResponse>> => + dispatchToMainStore( + serviceMessage.fetchValidatorsDetailsDataRequest({ publicKey }) + ); diff --git a/src/libs/ui/components/account-activity-plate/account-activity-plate.tsx b/src/libs/ui/components/account-activity-plate/account-activity-plate.tsx index 41e667f36..a22f632e3 100644 --- a/src/libs/ui/components/account-activity-plate/account-activity-plate.tsx +++ b/src/libs/ui/components/account-activity-plate/account-activity-plate.tsx @@ -6,12 +6,12 @@ import styled from 'styled-components'; import { AccountActivityPlateContainer, ActivityPlateContentContainer, + ActivityPlateDivider, + ActivityPlateIconCircleContainer, AlignedFlexRow, AlignedSpaceBetweenFlexRow, - ActivityPlateIconCircleContainer, - ActivityPlateDivider, - SpacingSize, - RightAlignedCenteredFlexRow + RightAlignedCenteredFlexRow, + SpacingSize } from '@libs/layout'; import { ContentColor, @@ -36,11 +36,13 @@ import { } from '@libs/services/account-activity-service'; import { RouterPath, useTypedNavigate } from '@popup/router'; import { - ShortTypeName, - TransferType, - TypeColors, - TypeIcons, - TypeName + ActivityShortTypeName, + ActivityType, + ActivityTypeColors, + ActivityTypeIcons, + ActivityTypeName, + AuctionManagerEntryPoint, + TokenEntryPoint } from '@src/constants'; import { getAccountHashFromPublicKey } from '@libs/entities/Account'; import { getRecipientAddressFromTransaction } from '@libs/ui/utils/utils'; @@ -59,7 +61,11 @@ type Ref = HTMLDivElement; export const AccountActivityPlate = forwardRef( ({ transactionInfo, onClick, isDeploysList }, ref) => { - const [type, setType] = useState(null); + const [type, setType] = useState(null); + const [fromAccount, setFromAccount] = useState( + undefined + ); + const [toAccount, setToAccount] = useState(undefined); const navigate = useTypedNavigate(); const { t } = useTranslation(); @@ -119,21 +125,57 @@ export const AccountActivityPlate = forwardRef( : '-'; useEffect(() => { + if ('entryPoint' in transactionInfo) { + switch (transactionInfo.entryPoint?.name) { + case AuctionManagerEntryPoint.undelegate: { + setType(ActivityType.Undelegated); + setFromAccount(transactionInfo.args.validator?.parsed as string); + setToAccount(transactionInfo.args.delegator?.parsed as string); + return; + } + case AuctionManagerEntryPoint.delegate: { + setType(ActivityType.Delegated); + setFromAccount(transactionInfo.args.delegator?.parsed as string); + setToAccount(transactionInfo.args.validator?.parsed as string); + return; + } + case AuctionManagerEntryPoint.redelegate: { + setType(ActivityType.Redelegated); + setFromAccount(transactionInfo.args.validator?.parsed as string); + setToAccount(transactionInfo.args.new_validator?.parsed as string); + return; + } + case TokenEntryPoint.mint: { + setType(ActivityType.Mint); + setFromAccount(transactionInfo.callerPublicKey); + setToAccount(recipientAddress); + return; + } + case TokenEntryPoint.burn: { + setType(ActivityType.Burn); + setFromAccount(transactionInfo.callerPublicKey); + setToAccount(undefined); + return; + } + } + } + if (fromAccountPublicKey === activeAccount?.publicKey) { - setType(TransferType.Sent); + setType(ActivityType.Sent); } else if ( recipientAddress === activeAccount?.publicKey || recipientAddress === activeAccountHash ) { - setType(TransferType.Received); + setType(ActivityType.Received); } else { - setType(TransferType.Unknown); + setType(ActivityType.Unknown); } }, [ fromAccountPublicKey, activeAccount?.publicKey, recipientAddress, - activeAccountHash + activeAccountHash, + transactionInfo ]); return ( @@ -144,8 +186,8 @@ export const AccountActivityPlate = forwardRef( navigate(RouterPath.ActivityDetails, { state: { activityDetailsData: { - fromAccount: fromAccountPublicKey, - toAccount: recipientAddress, + fromAccount: fromAccount || fromAccountPublicKey, + toAccount: toAccount || recipientAddress, deployHash, type, amount: formattedAmount, @@ -162,9 +204,9 @@ export const AccountActivityPlate = forwardRef( {type != null && ( )} @@ -175,8 +217,8 @@ export const AccountActivityPlate = forwardRef( {type != null && (formattedAmount.length >= 13 - ? ShortTypeName[type] - : TypeName[type])} + ? ActivityShortTypeName[type] + : ActivityTypeName[type])} @@ -186,7 +228,9 @@ export const AccountActivityPlate = forwardRef( formattedAmount ) : ( <> - {type === TransferType.Sent ? '-' : ''} + {type === ActivityType.Sent || type === ActivityType.Delegated + ? '-' + : ''} {formattedAmount} )} diff --git a/src/libs/ui/components/account-casper-activity-plate/account-casper-activity-plate.tsx b/src/libs/ui/components/account-casper-activity-plate/account-casper-activity-plate.tsx index f5b75b2d1..eccb9a2a6 100644 --- a/src/libs/ui/components/account-casper-activity-plate/account-casper-activity-plate.tsx +++ b/src/libs/ui/components/account-casper-activity-plate/account-casper-activity-plate.tsx @@ -28,11 +28,11 @@ import { SpacingSize } from '@libs/layout'; import { - ShortTypeName, - TransferType, - TypeColors, - TypeIcons, - TypeName + ActivityShortTypeName, + ActivityType, + ActivityTypeColors, + ActivityTypeIcons, + ActivityTypeName } from '@src/constants'; import { TransferResultWithId } from '@libs/services/account-activity-service'; import { getAccountHashFromPublicKey } from '@libs/entities/Account'; @@ -47,7 +47,7 @@ export const AccountCasperActivityPlate = forwardRef< Ref, AccountCasperActivityPlateProps >(({ transactionInfo, onClick }, ref) => { - const [type, setType] = useState(null); + const [type, setType] = useState(null); const navigate = useTypedNavigate(); const { t } = useTranslation(); @@ -77,14 +77,14 @@ export const AccountCasperActivityPlate = forwardRef< fromAccountPublicKey === activeAccount?.publicKey || fromAccount === activeAccountHash ) { - setType(TransferType.Sent); + setType(ActivityType.Sent); } else if ( toAccountPublicKey === activeAccount?.publicKey || toAccount === activeAccountHash ) { - setType(TransferType.Received); + setType(ActivityType.Received); } else { - setType(TransferType.Unknown); + setType(ActivityType.Unknown); } }, [ fromAccountPublicKey, @@ -119,9 +119,9 @@ export const AccountCasperActivityPlate = forwardRef< {type != null && ( )} @@ -132,13 +132,13 @@ export const AccountCasperActivityPlate = forwardRef< {type != null && (formattedAmount.length >= 13 - ? ShortTypeName[type] - : TypeName[type])} + ? ActivityShortTypeName[type] + : ActivityTypeName[type])} - {type === TransferType.Sent ? '-' : ''} + {type === ActivityType.Sent ? '-' : ''} {formattedAmount} diff --git a/src/libs/ui/components/button/button.tsx b/src/libs/ui/components/button/button.tsx index 798eae925..cec9df596 100644 --- a/src/libs/ui/components/button/button.tsx +++ b/src/libs/ui/components/button/button.tsx @@ -42,7 +42,6 @@ const BaseButton = styled.button( ...(circle && { borderRadius: '24px', - margin: '0 16px', padding: '12px' }), diff --git a/src/libs/ui/components/error/error.tsx b/src/libs/ui/components/error/error.tsx new file mode 100644 index 000000000..319c0b48e --- /dev/null +++ b/src/libs/ui/components/error/error.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Trans, useTranslation } from 'react-i18next'; + +import { AlignedFlexRow, FlexColumn, SpacingSize } from '@libs/layout'; +import { SvgIcon, Typography } from '@libs/ui'; + +const ErrorContainer = styled(FlexColumn)` + padding: 12px 16px; + + background: ${({ theme }) => theme.color.backgroundPrimary}; +`; + +interface ErrorProps { + header: string; + description: string; +} + +export const Error = ({ header, description }: ErrorProps) => { + const { t } = useTranslation(); + + return ( + + + + + {header} + + + + {description} + + + ); +}; diff --git a/src/libs/ui/components/hash/utils.ts b/src/libs/ui/components/hash/utils.ts index 6291b1acc..581626a37 100644 --- a/src/libs/ui/components/hash/utils.ts +++ b/src/libs/ui/components/hash/utils.ts @@ -12,8 +12,8 @@ export function truncateKey( break; case 'small': default: - beginOfKey = key.slice(0, 5); - endOfKey = key.slice(key.length - 5); + beginOfKey = key.slice(0, 4); + endOfKey = key.slice(key.length - 4); break; case 'medium': diff --git a/src/libs/ui/components/input/input.tsx b/src/libs/ui/components/input/input.tsx index 95ee67572..cc0249278 100644 --- a/src/libs/ui/components/input/input.tsx +++ b/src/libs/ui/components/input/input.tsx @@ -170,7 +170,7 @@ export const Input = React.forwardRef(function Input( : { [InputValidationType.Password]: { type: 'password', - min: '12', + min: '16', max: '0', step: '0' } @@ -231,5 +231,3 @@ export const Input = React.forwardRef(function Input( ); }); - -export default Input; diff --git a/src/libs/ui/components/list/list.tsx b/src/libs/ui/components/list/list.tsx index b5dffae12..5cf3e462c 100644 --- a/src/libs/ui/components/list/list.tsx +++ b/src/libs/ui/components/list/list.tsx @@ -44,7 +44,7 @@ const RowContainer = styled(FlexColumn)``; const ListHeaderContainer = styled(FlexColumn)` ${({ stickyHeader, theme }) => stickyHeader - ? `position: sticky; top: 72px; z-index: 1; background: ${theme.color.backgroundSecondary}};` + ? `position: sticky; top: 72px; z-index: 2; background: ${theme.color.backgroundSecondary}};` : ''}; &::after { @@ -79,6 +79,7 @@ interface ListProps { marginLeftForItemSeparatorLine: number; stickyHeader?: boolean; maxHeight?: number; + borderRadius?: 'base'; } export function List({ @@ -93,8 +94,14 @@ export function List({ headerLabelTop = SpacingSize.XL, contentTop = SpacingSize.XL, stickyHeader, - maxHeight + maxHeight, + borderRadius }: ListProps) { + const separatorLine = + marginLeftForHeaderSeparatorLine || marginLeftForHeaderSeparatorLine === 0 + ? marginLeftForHeaderSeparatorLine + : marginLeftForItemSeparatorLine; + return ( <> {headerLabel && ( @@ -119,13 +126,10 @@ export function List({ )} - + {renderHeader && ( {renderHeader()} diff --git a/src/libs/ui/components/modal/modal.tsx b/src/libs/ui/components/modal/modal.tsx index 439e8ec5b..03214b238 100644 --- a/src/libs/ui/components/modal/modal.tsx +++ b/src/libs/ui/components/modal/modal.tsx @@ -9,20 +9,24 @@ const ChildrenContainer = styled(AlignedFlexRow)` cursor: pointer; `; -const ModalContainer = styled.div` - position: fixed; - top: 88px; - left: 0; - right: 0; +const ModalContainer = styled.div<{ placement: 'top' | 'bottom' }>( + ({ theme, placement }) => ({ + position: 'fixed', + top: placement === 'top' ? '88px' : undefined, + bottom: placement === 'bottom' ? '16px' : undefined, + left: 0, + right: 0, - margin: 0 16px; + margin: '0 16px', - max-width: 328px; + maxWidth: '328px', + + backgroundColor: theme.color.backgroundPrimary, + boxShadow: theme.shadow.contextMenu, + borderRadius: `${theme.borderRadius.twelve}px` + }) +); - background-color: ${({ theme }) => theme.color.backgroundPrimary}; - box-shadow: ${({ theme }) => theme.shadow.contextMenu}; - border-radius: ${({ theme }) => theme.borderRadius.twelve}px; -`; interface RenderChildrenProps { isOpen: boolean; } @@ -34,9 +38,10 @@ interface RenderContentProps { export interface ModalProps extends BaseProps { children: (renderProps: RenderChildrenProps) => React.ReactNode | string; renderContent: (renderProps: RenderContentProps) => React.ReactNode | string; + placement: 'top' | 'bottom'; } -export const Modal = ({ children, renderContent }: ModalProps) => { +export const Modal = ({ children, renderContent, placement }: ModalProps) => { const [isOpen, setIsOpen] = useState(false); const childrenContainerRef = useRef(null); @@ -66,7 +71,7 @@ export const Modal = ({ children, renderContent }: ModalProps) => { {isOpen && ( - + {renderContent({ closeModal })} diff --git a/src/libs/ui/components/recipient-plate/recipient-plate.tsx b/src/libs/ui/components/recipient-plate/recipient-plate.tsx index dd6bbde9b..dabefbc4d 100644 --- a/src/libs/ui/components/recipient-plate/recipient-plate.tsx +++ b/src/libs/ui/components/recipient-plate/recipient-plate.tsx @@ -17,7 +17,7 @@ const PublicKeyOptionContainer = styled(FlexRow)<{ onClick?: () => void }>` padding: 12px 16px; background-color: ${({ theme }) => theme.color.backgroundPrimary}; - border-radius: ${({ theme }) => theme.borderRadius.eight}px; + border-radius: ${({ theme }) => theme.borderRadius.base}px; `; export const RecipientPlate = ({ diff --git a/src/libs/ui/components/tabs/tabs.tsx b/src/libs/ui/components/tabs/tabs.tsx index ad239b6e8..abb601ca4 100644 --- a/src/libs/ui/components/tabs/tabs.tsx +++ b/src/libs/ui/components/tabs/tabs.tsx @@ -14,7 +14,7 @@ const TabsContainer = styled(AlignedSpaceBetweenFlexRow)` const StickyTabsContainer = styled.div` position: sticky; - top: -2px; + top: 0; z-index: 5; padding: 16px 0; diff --git a/src/libs/ui/components/tile/tile.tsx b/src/libs/ui/components/tile/tile.tsx index a00c2c64d..47a21f333 100644 --- a/src/libs/ui/components/tile/tile.tsx +++ b/src/libs/ui/components/tile/tile.tsx @@ -1,8 +1,9 @@ import styled from 'styled-components'; -export const Tile = styled.div` +export const Tile = styled.div<{ borderRadius?: 'base' }>` width: 100%; background-color: ${({ theme }) => theme.color.backgroundPrimary}; - border-radius: ${({ theme }) => theme.borderRadius.twelve}px; + border-radius: ${({ theme, borderRadius }) => + borderRadius ? theme.borderRadius.base : theme.borderRadius.twelve}px; `; diff --git a/src/libs/ui/components/transfer-success-screen/transfer-succeess-screen.tsx b/src/libs/ui/components/transfer-success-screen/transfer-succeess-screen.tsx index c5f01e586..d78e94930 100644 --- a/src/libs/ui/components/transfer-success-screen/transfer-succeess-screen.tsx +++ b/src/libs/ui/components/transfer-success-screen/transfer-succeess-screen.tsx @@ -10,11 +10,11 @@ import { import { SvgIcon, Typography } from '@libs/ui'; interface TransferSuccessScreenProps { - isNftTransfer?: boolean; + headerText: string; } export const TransferSuccessScreen = ({ - isNftTransfer = false + headerText }: TransferSuccessScreenProps) => { const { t } = useTranslation(); @@ -28,11 +28,7 @@ export const TransferSuccessScreen = ({ /> - - {isNftTransfer - ? 'You’ve sent the NFT' - : 'You submitted a transaction'} - + {headerText} diff --git a/src/libs/ui/components/validator-dropdown-input/validator-dropdown-input.tsx b/src/libs/ui/components/validator-dropdown-input/validator-dropdown-input.tsx new file mode 100644 index 000000000..c51b265ba --- /dev/null +++ b/src/libs/ui/components/validator-dropdown-input/validator-dropdown-input.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { UseFormReturn, useWatch } from 'react-hook-form'; +import { Trans, useTranslation } from 'react-i18next'; +import styled from 'styled-components'; + +import { + AlignedSpaceBetweenFlexRow, + SpacingSize, + VerticalSpaceContainer +} from '@src/libs/layout'; +import { SvgIcon, Input, List, ValidatorPlate, Typography } from '@libs/ui'; +import { StakeValidatorFormValues } from '@libs/ui/forms/stakes-form'; +import { useClickAway } from '@libs/ui/hooks/use-click-away'; +import { ValidatorResultWithId } from '@libs/services/validators-service/types'; +import { AuctionManagerEntryPoint } from '@src/constants'; + +const DropDownHeader = styled(AlignedSpaceBetweenFlexRow)` + padding: 8px 16px; + + border-top-left-radius: ${({ theme }) => theme.borderRadius.base}px; + border-top-right-radius: ${({ theme }) => theme.borderRadius.base}px; + + background-color: ${({ theme }) => theme.color.backgroundPrimary}; +`; + +interface ValidatorDropdownInputProps { + validatorForm: UseFormReturn; + validatorList: ValidatorResultWithId[] | null; + validator: ValidatorResultWithId | null; + setValidator: React.Dispatch< + React.SetStateAction + >; + setStakeAmount: React.Dispatch>; + stakesType: AuctionManagerEntryPoint; +} + +export const ValidatorDropdownInput = ({ + validatorForm, + validatorList, + validator, + setValidator, + setStakeAmount, + stakesType +}: ValidatorDropdownInputProps) => { + const [isOpenValidatorPublicKeysList, setIsOpenValidatorPublicKeysList] = + useState(true); + const [showValidatorPlate, setShowValidatorPlate] = useState(false); + const [label, setLabel] = useState(''); + + const { t } = useTranslation(); + + const { register, formState, setValue, control, trigger } = validatorForm; + const { errors } = formState; + + const inputValue = useWatch({ + control: control, + name: 'validatorPublicKey' + }); + + const { ref: clickAwayRef } = useClickAway({ + callback: async () => { + setIsOpenValidatorPublicKeysList(false); + + if (validator && inputValue !== '') { + setShowValidatorPlate(true); + setValue('validatorPublicKey', validator.public_key); + setStakeAmount(validator.user_stake!); + await trigger('validatorPublicKey'); + return; + } else if (validator && inputValue === '') { + setShowValidatorPlate(false); + setValue('validatorPublicKey', ''); + setValidator(null); + await trigger('validatorPublicKey'); + return; + } + + setValue('validatorPublicKey', ''); + await trigger('validatorPublicKey'); + } + }); + + useEffect(() => { + trigger('validatorPublicKey'); + }, [trigger, validator]); + + useEffect(() => { + if (formState.isValid) { + setShowValidatorPlate(true); + } + // This should trigger only once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const useFilteredValidators = ( + inputValue: string, + validatorList: ValidatorResultWithId[] | null + ) => { + const filterValidators = useCallback( + ( + inputValue: string, + validatorList: ValidatorResultWithId[] | null + ): ValidatorResultWithId[] | [] => { + if (!validatorList) return []; + if (!inputValue) return validatorList; + + return validatorList.filter(validator => { + const { public_key } = validator; + if (validator?.account_info?.info?.owner?.name) { + const { name } = validator.account_info.info.owner; + + return ( + name?.toLowerCase().includes(inputValue?.toLowerCase()) || + public_key?.toLowerCase().includes(inputValue?.toLowerCase()) + ); + } + + return public_key?.toLowerCase().includes(inputValue?.toLowerCase()); + }); + }, + [] + ); + + return filterValidators(inputValue, validatorList); + }; + + const filteredValidatorsList = useFilteredValidators( + inputValue, + validatorList + ); + + useEffect(() => { + switch (stakesType) { + case AuctionManagerEntryPoint.delegate: { + setLabel('To validator'); + break; + } + case AuctionManagerEntryPoint.undelegate: { + setLabel('From validator'); + break; + } + + default: + throw Error('fetch validator: unknown stakes type'); + } + }, [stakesType]); + + return showValidatorPlate && validator ? ( + + { + setShowValidatorPlate(false); + setIsOpenValidatorPublicKeysList(true); + }} + /> + + ) : ( + { + setIsOpenValidatorPublicKeysList(true); + }} + > + {/*TODO: create Select component and rewrite this*/} + } + suffixIcon={ + + } + placeholder={t('Validator public address')} + {...register('validatorPublicKey')} + autoComplete="off" + /> + {isOpenValidatorPublicKeysList && ( + ( + + + Validator + + + Total stake, fee, delegators + + + )} + renderRow={validator => ( + { + setValue('validatorPublicKey', validator.public_key); + setStakeAmount(validator.user_stake!); + + setValidator(validator); + + setIsOpenValidatorPublicKeysList(false); + setShowValidatorPlate(true); + }} + /> + )} + marginLeftForItemSeparatorLine={56} + marginLeftForHeaderSeparatorLine={0} + /> + )} + + ); +}; diff --git a/src/libs/ui/components/validator-plate/validator-plate.tsx b/src/libs/ui/components/validator-plate/validator-plate.tsx new file mode 100644 index 000000000..db62bb484 --- /dev/null +++ b/src/libs/ui/components/validator-plate/validator-plate.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { FieldError } from 'react-hook-form'; + +import { + AlignedFlexRow, + AlignedSpaceBetweenFlexRow, + FlexColumn, + FlexRow, + RightAlignedFlexColumn, + SpacingSize, + VerticalSpaceContainer +} from '@libs/layout'; +import { + Avatar, + Error, + FormField, + Hash, + HashVariant, + Typography +} from '@libs/ui'; +import { formatNumber, motesToCSPR } from '@libs/ui/utils/formatters'; +import { getImageProxyUrl } from '@src/utils'; + +const ValidatorPlateContainer = styled(AlignedSpaceBetweenFlexRow)<{ + onClick?: () => void; + withBackground?: boolean; +}>` + cursor: ${({ onClick }) => (onClick ? 'pointer' : 'initial')}; + background: ${({ withBackground, theme }) => + withBackground ? theme.color.backgroundPrimary : 'transparent'}; + border-radius: ${({ theme }) => theme.borderRadius.base}px; + + padding: 8px 16px; +`; + +const NameContainer = styled(FlexColumn)` + max-width: 93px; +`; + +const IconContainer = styled.div` + padding-top: 8px; +`; + +const Image = styled.img` + height: 24px; + width: 24px; +`; + +interface ValidatorPlateProps { + handleClick?: () => void; + publicKey: string; + name?: string; + logo?: string; + showFullPublicKey?: boolean; + fee: number; + delegatorsNumber?: number; + validatorLabel?: string; + error?: FieldError; + totalStake?: string; +} + +export const ValidatorPlate = ({ + publicKey, + name, + showFullPublicKey, + fee, + handleClick, + logo, + delegatorsNumber, + validatorLabel, + error, + totalStake +}: ValidatorPlateProps) => { + const [formattedTotalStake, setFormattedTotalStake] = useState(''); + + useEffect(() => { + if (totalStake) { + setFormattedTotalStake(formatNumber(motesToCSPR(totalStake))); + } + }, [totalStake]); + + const logoUrl = getImageProxyUrl(logo); + const formattedFee = formatNumber(fee, { + precision: { min: 2 } + }); + const getFormattedDelegatorsNumber = () => { + if (delegatorsNumber && delegatorsNumber >= 1000) { + return ( + formatNumber(delegatorsNumber / 1000, { + precision: { max: 2 } + }) + 'k' + ); + } + + return delegatorsNumber; + }; + + const plateWithFullPublicKey = ( + + + + {logoUrl ? ( + {name} + ) : ( + + )} + + + + + {name} + + + + + ); + + const plate = (withBackground?: boolean) => ( + + + {logoUrl ? ( + {name} + ) : ( + + )} + + + + {name} + + + + + + {`${formattedTotalStake} CSPR`} + + + + {`${formattedFee}% fee`} + + + {getFormattedDelegatorsNumber()} delegators + + + + + ); + + if (validatorLabel) { + return ( + <> + + {showFullPublicKey ? plateWithFullPublicKey : plate(true)} + + {error && error.message && ( + + + + )} + + ); + } + + return showFullPublicKey ? plateWithFullPublicKey : plate(); +}; diff --git a/src/libs/ui/forms/form-validation-rules.ts b/src/libs/ui/forms/form-validation-rules.ts index e5ef55850..a8cfda497 100644 --- a/src/libs/ui/forms/form-validation-rules.ts +++ b/src/libs/ui/forms/form-validation-rules.ts @@ -8,9 +8,13 @@ import { dispatchToMainStore } from '@src/background/redux/utils'; import { loginRetryCountIncremented } from '@src/background/redux/login-retry-count/actions'; import { selectLoginRetryCount } from '@background/redux/login-retry-count/selectors'; import { + STAKE_COST_MOTES, + DELEGATION_MIN_AMOUNT_MOTES, LOGIN_RETRY_ATTEMPTS_LIMIT, + MAX_DELEGATORS, + TRANSFER_COST_MOTES, TRANSFER_MIN_AMOUNT_MOTES, - TRANSFER_COST_MOTES + AuctionManagerEntryPoint } from '@src/constants'; import { isValidPublicKey, isValidU64 } from '@src/utils'; import { CSPRtoMotes, motesToCSPR } from '@libs/ui/utils/formatters'; @@ -128,7 +132,7 @@ export const useRecipientPublicKeyRule = () => { }); }; -export const useCsprAmountRule = (amountMotes: string | null) => { +export const useCSPRTransferAmountRule = (amountMotes: string | null) => { const { t } = useTranslation(); const maxAmountMotes: string = @@ -269,3 +273,117 @@ export const usePaymentAmountRule = (csprBalance: string | null) => { ) }); }; + +export const useCSPRStakeAmountRule = ( + amountMotes: string | null, + mode: AuctionManagerEntryPoint, + stakeAmountMotes: string +) => { + const { t } = useTranslation(); + + const getStakeMinAmountMotes = () => { + switch (mode) { + case AuctionManagerEntryPoint.delegate: { + return DELEGATION_MIN_AMOUNT_MOTES; + } + case AuctionManagerEntryPoint.undelegate: { + return '0'; + } + + default: { + return DELEGATION_MIN_AMOUNT_MOTES; + } + } + }; + + const maxAmountMotes: string = + amountMotes == null + ? '0' + : Big(amountMotes).sub(STAKE_COST_MOTES).toString(); + + return Yup.string() + .required({ + header: t('Amount is required'), + description: t('You need to enter an amount to stake') + }) + .test({ + name: 'validU64', + test: csprAmountInputValue => { + if (csprAmountInputValue) { + return isValidU64(csprAmountInputValue); + } + + return false; + }, + message: { + header: t(`Amount is invalid`), + description: t(`You need to enter a valid amount`) + } + }) + .test({ + name: 'amountBelowMinTransfer', + test: csprAmountInputValue => { + if (csprAmountInputValue) { + return Big(CSPRtoMotes(csprAmountInputValue)).gte( + getStakeMinAmountMotes() + ); + } + + return false; + }, + message: { + header: t('You can’t delegate this amount'), + description: t( + `The minimum required delegation amount is ${motesToCSPR( + getStakeMinAmountMotes() + )} CSPR.` + ) + } + }) + .test({ + name: 'amountAboveBalance', + test: csprAmountInputValue => { + if (csprAmountInputValue) { + if (mode === AuctionManagerEntryPoint.undelegate) { + return Big(CSPRtoMotes(csprAmountInputValue)).lte( + Big(stakeAmountMotes).sub(getStakeMinAmountMotes()).toString() + ); + } + return Big(CSPRtoMotes(csprAmountInputValue)).lte(maxAmountMotes); + } + + return false; + }, + message: + mode === AuctionManagerEntryPoint.undelegate + ? { + header: t('You can’t undelegate this amount'), + description: t('Amount must be less than staked CSPR.') + } + : { + header: t('Your account balance is not high enough'), + description: t( + 'Your account balance is not high enough. Enter a smaller amount.' + ) + } + }); +}; + +export const useValidatorPublicKeyRule = (delegatorsNumber?: number) => { + const { t } = useTranslation(); + + return Yup.string() + .required(t('Recipient is required')) + .test({ + name: 'validatorPublicKey', + test: value => (value ? isValidPublicKey(value) : false), + message: t('Recipient should be a valid public key') + }) + .test({ + name: 'maxDelegators', + test: () => !(delegatorsNumber && delegatorsNumber >= MAX_DELEGATORS), + message: t( + 'This validator has reached the network limit for total delegators and therefore cannot be delegated to by new accounts. Please select another validator with fewer than 1200 total delegators' + ) + }); +}; diff --git a/src/libs/ui/forms/stakes-form.ts b/src/libs/ui/forms/stakes-form.ts new file mode 100644 index 000000000..5a96a6ab7 --- /dev/null +++ b/src/libs/ui/forms/stakes-form.ts @@ -0,0 +1,50 @@ +import * as Yup from 'yup'; +import { useForm } from 'react-hook-form'; +import { UseFormProps } from 'react-hook-form/dist/types/form'; +import { yupResolver } from '@hookform/resolvers/yup/dist/yup'; + +import { + useCSPRStakeAmountRule, + useValidatorPublicKeyRule +} from '@libs/ui/forms/form-validation-rules'; +import { AuctionManagerEntryPoint } from '@src/constants'; + +export type StakeValidatorFormValues = { + validatorPublicKey: string; +}; + +export type StakeAmountFormValues = { + amount: string; +}; + +export const useStakesForm = ( + amountMotes: string | null, + stakesType: AuctionManagerEntryPoint, + stakeAmountMotes: string, + delegatorsNumber?: number +) => { + const validatorFormSchema = Yup.object().shape({ + validatorPublicKey: useValidatorPublicKeyRule(delegatorsNumber) + }); + + const validatorFormOptions: UseFormProps = { + reValidateMode: 'onChange', + mode: 'onChange', + resolver: yupResolver(validatorFormSchema) + }; + + const amountFormSchema = Yup.object().shape({ + amount: useCSPRStakeAmountRule(amountMotes, stakesType, stakeAmountMotes) + }); + + const amountFormOptions: UseFormProps = { + reValidateMode: 'onChange', + mode: 'onChange', + resolver: yupResolver(amountFormSchema) + }; + + return { + validatorForm: useForm(validatorFormOptions), + amountForm: useForm(amountFormOptions) + }; +}; diff --git a/src/libs/ui/forms/transfer.ts b/src/libs/ui/forms/transfer.ts index 4adaf0d6d..9580ba518 100644 --- a/src/libs/ui/forms/transfer.ts +++ b/src/libs/ui/forms/transfer.ts @@ -3,7 +3,7 @@ import { useForm } from 'react-hook-form'; import { UseFormProps } from 'react-hook-form/dist/types/form'; import { - useCsprAmountRule, + useCSPRTransferAmountRule, useErc20AmountRule, usePaymentAmountRule, useRecipientPublicKeyRule, @@ -46,7 +46,7 @@ export function useTransferForm( }); const csprAmountFormSchema = Yup.object().shape({ - amount: useCsprAmountRule(amountMotes), + amount: useCSPRTransferAmountRule(amountMotes), transferIdMemo: useTransferIdMemoRule() }); diff --git a/src/libs/ui/index.ts b/src/libs/ui/index.ts index 83c5e446f..bf044dc0a 100644 --- a/src/libs/ui/index.ts +++ b/src/libs/ui/index.ts @@ -45,6 +45,9 @@ export * from './components/contract-icon/contract-icon'; export * from './components/password-inputs/password-inputs'; export * from './components/toggle/toggle'; export * from './components/skeleton/skeleton'; +export * from './components/validator-dropdown-input/validator-dropdown-input'; +export * from './components/validator-plate/validator-plate'; +export * from './components/error/error'; export * from './utils/match-media'; export * from './utils/match-size';