diff --git a/src/renderer/entities/transaction/ui/QrCode/QrGeneratorContainer/QrGeneratorContainer.tsx b/src/renderer/entities/transaction/ui/QrCode/QrGeneratorContainer/QrGeneratorContainer.tsx index 2a1b149005..1491910732 100644 --- a/src/renderer/entities/transaction/ui/QrCode/QrGeneratorContainer/QrGeneratorContainer.tsx +++ b/src/renderer/entities/transaction/ui/QrCode/QrGeneratorContainer/QrGeneratorContainer.tsx @@ -7,11 +7,11 @@ import { getMetadataPortalMetadataUrl, TROUBLESHOOTING_URL } from '../common/con type Props = { countdown: number; - onQrReset: () => void; chainId: ChainId; + onQrReset: () => void; }; -export const QrGeneratorContainer = ({ countdown, onQrReset, chainId, children }: PropsWithChildren) => { +export const QrGeneratorContainer = ({ countdown, chainId, children, onQrReset }: PropsWithChildren) => { const { t } = useI18n(); return ( diff --git a/src/renderer/entities/transaction/ui/Scanning/ScanMultiframeQr.tsx b/src/renderer/entities/transaction/ui/Scanning/ScanMultiframeQr.tsx index 1ee9092564..900bf3a2bd 100644 --- a/src/renderer/entities/transaction/ui/Scanning/ScanMultiframeQr.tsx +++ b/src/renderer/entities/transaction/ui/Scanning/ScanMultiframeQr.tsx @@ -7,13 +7,14 @@ import { useEffect, useState } from 'react'; import { useI18n } from '@app/providers'; import { Transaction, useTransaction } from '@entities/transaction'; import { toAddress } from '@shared/lib/utils'; -import { Button } from '@shared/ui'; +import { Button, FootnoteText } from '@shared/ui'; import type { Account, BaseAccount, ChainId, ShardAccount } from '@shared/core'; import { SigningType, Wallet } from '@shared/core'; import { createSubstrateSignPayload, createMultipleSignPayload } from '../QrCode/QrGenerator/common/utils'; import { TRANSACTION_BULK } from '../QrCode/common/constants'; import { QrMultiframeGenerator } from '../QrCode/QrGenerator/QrMultiframeTxGenerator'; import { QrGeneratorContainer } from '../QrCode/QrGeneratorContainer/QrGeneratorContainer'; +import { WalletIcon } from '../../../wallet'; type Props = { api: ApiPromise; @@ -103,16 +104,27 @@ export const ScanMultiframeQr = ({ return (
-
- - {bulkTxExist && encoder && } - -
+ {accounts.length > 0 && ( +
+
+ {t('signing.signer')} + +
+ + {signerWallet.name} +
+
+
+ )} + + + {bulkTxExist && encoder && } + +
- diff --git a/src/renderer/entities/transaction/ui/Scanning/ScanSingleframeQr.tsx b/src/renderer/entities/transaction/ui/Scanning/ScanSingleframeQr.tsx index 746a50b048..0da6f61089 100644 --- a/src/renderer/entities/transaction/ui/Scanning/ScanSingleframeQr.tsx +++ b/src/renderer/entities/transaction/ui/Scanning/ScanSingleframeQr.tsx @@ -66,8 +66,8 @@ export const ScanSingleframeQr = ({ return (
-
- {account && ( + {account && ( +
{t('signing.signer')} @@ -76,8 +76,8 @@ export const ScanSingleframeQr = ({ {signerWallet.name}
- )} -
+
+ )} {txPayload && ( diff --git a/src/renderer/pages/Staking/Operations/Bond/Bond.tsx b/src/renderer/pages/Staking/Operations/Bond/Bond.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/renderer/pages/Staking/Operations/ChangeValidators/ChangeValidators.tsx b/src/renderer/pages/Staking/Operations/ChangeValidators/ChangeValidators.tsx deleted file mode 100644 index df9af601f9..0000000000 --- a/src/renderer/pages/Staking/Operations/ChangeValidators/ChangeValidators.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { UnsignedTransaction } from '@substrate/txwrapper-polkadot'; -import { useState, useEffect } from 'react'; -import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { useUnit } from 'effector-react'; - -import { useI18n } from '@app/providers'; -import { Paths } from '@shared/routes'; -import { Transaction, TransactionType, useTransaction } from '@entities/transaction'; -import { ValidatorMap, StakingPopover } from '@entities/staking'; -import { toAddress, getRelaychainAsset, DEFAULT_TRANSITION } from '@shared/lib/utils'; -import { Confirmation, Submit, Validators, NoAsset } from '../components'; -import { useToggle } from '@shared/lib/hooks'; -import { BaseModal, Button, Loader } from '@shared/ui'; -import InitOperation, { ValidatorsResult } from './InitOperation/InitOperation'; -import { useNetworkData, networkUtils } from '@entities/network'; -import { OperationTitle } from '@entities/chain'; -import { SigningSwitch } from '@features/operations'; -import { Account, ChainId, HexString, Address, MultisigAccount } from '@shared/core'; -import { walletModel, walletUtils } from '@entities/wallet'; -import { priceProviderModel } from '@entities/price'; - -const enum Step { - INIT, - VALIDATORS, - CONFIRMATION, - SIGNING, - SUBMIT, -} - -export const ChangeValidators = () => { - const { t } = useI18n(); - const activeWallet = useUnit(walletModel.$activeWallet); - const activeAccounts = useUnit(walletModel.$activeAccounts); - - const navigate = useNavigate(); - const { setTxs, txs, setWrappers, wrapTx, buildTransaction } = useTransaction(); - const [searchParams] = useSearchParams(); - const params = useParams<{ chainId: ChainId }>(); - - const { api, chain, connection } = useNetworkData(params.chainId); - - const [isValidatorsModalOpen, toggleValidatorsModal] = useToggle(true); - - const [activeStep, setActiveStep] = useState(Step.INIT); - const [validators, setValidators] = useState({}); - - const [description, setDescription] = useState(''); - - const [unsignedTransactions, setUnsignedTransactions] = useState([]); - - const [accounts, setAccounts] = useState([]); - const [txAccounts, setTxAccounts] = useState([]); - const [signer, setSigner] = useState(); - const [signatures, setSignatures] = useState([]); - - const isMultisigWallet = walletUtils.isMultisig(activeWallet); - - const accountIds = searchParams.get('id')?.split(',') || []; - const chainId = params.chainId || ('' as ChainId); - - useEffect(() => { - priceProviderModel.events.assetsPricesRequested({ includeRates: true }); - }, []); - - useEffect(() => { - if (!activeAccounts.length || !accountIds.length) return; - - const accounts = activeAccounts.filter((a) => a.id && accountIds.includes(a.id.toString())); - setAccounts(accounts); - }, [activeAccounts.length]); - - if (!api || accountIds.length === 0) { - return ; - } - - const { explorers, addressPrefix, assets, name } = chain; - const asset = getRelaychainAsset(assets); - - const goToPrevStep = () => { - if (activeStep === Step.INIT) { - navigate(Paths.STAKING); - } else { - setActiveStep((prev) => prev - 1); - } - }; - - const closeValidatorsModal = () => { - toggleValidatorsModal(); - setTimeout(() => navigate(Paths.STAKING), DEFAULT_TRANSITION); - }; - - if (!api?.isConnected) { - return ( - } - onClose={closeValidatorsModal} - > -
- - -
-
- ); - } - - if (!asset) { - return ( - } - onClose={closeValidatorsModal} - > -
- -
-
- ); - } - - const onInitResult = ({ accounts, signer, description }: ValidatorsResult) => { - if (signer && isMultisigWallet) { - setSigner(signer); - setDescription(description || ''); - } - - setTxAccounts(accounts); - setActiveStep(Step.VALIDATORS); - }; - - const onSelectValidators = (validators: ValidatorMap) => { - const transactions = getNominateTxs(Object.keys(validators)); - - if (signer && isMultisigWallet) { - setWrappers([ - { - signatoryId: signer.accountId, - account: txAccounts[0] as MultisigAccount, - }, - ]); - } - - setTxs(transactions); - setValidators(validators); - setActiveStep(Step.CONFIRMATION); - }; - - const getNominateTxs = (validators: Address[]): Transaction[] => { - return txAccounts.map(({ accountId }) => { - const address = toAddress(accountId, { prefix: addressPrefix }); - - return buildTransaction(TransactionType.NOMINATE, address, chainId, { targets: validators }); - }); - }; - - const onSignResult = (signatures: HexString[], unsigned: UnsignedTransaction[]) => { - setUnsignedTransactions(unsigned); - setSignatures(signatures); - setActiveStep(Step.SUBMIT); - }; - - const explorersProps = { explorers, addressPrefix, asset }; - const multisigTx = isMultisigWallet ? wrapTx(txs[0], api, addressPrefix) : undefined; - - return ( - <> - } - onClose={closeValidatorsModal} - > - {activeStep === Step.INIT && ( - - )} - {activeStep === Step.VALIDATORS && ( - - )} - {activeStep === Step.CONFIRMATION && ( - setActiveStep(Step.SIGNING)} - onGoBack={goToPrevStep} - {...explorersProps} - > - - {t('staking.confirmation.hintNewValidators')} - - - )} - {activeStep === Step.SIGNING && ( - setActiveStep(Step.CONFIRMATION)} - onResult={onSignResult} - /> - )} - - {activeStep === Step.SUBMIT && ( - - )} - - ); -}; diff --git a/src/renderer/pages/Staking/Operations/ChangeValidators/InitOperation/InitOperation.tsx b/src/renderer/pages/Staking/Operations/ChangeValidators/InitOperation/InitOperation.tsx deleted file mode 100644 index 004236f32c..0000000000 --- a/src/renderer/pages/Staking/Operations/ChangeValidators/InitOperation/InitOperation.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { ApiPromise } from '@polkadot/api'; -import { useEffect, useState } from 'react'; -import { useUnit } from 'effector-react'; - -import { useI18n } from '@app/providers'; -import { getOperationErrors, Transaction, TransactionType } from '@entities/transaction'; -import { validatorsService } from '@entities/staking'; -import { toAddress, nonNullable } from '@shared/lib/utils'; -import { OperationFooter, OperationHeader } from '@features/operations'; -import { OperationForm } from '../../components'; -import { Balance as AccountBalance, Account, Asset, MultisigAccount, ChainId, AccountId, Wallet } from '@shared/core'; -import { accountUtils, walletModel, walletUtils } from '@entities/wallet'; -import { useAssetBalances } from '@entities/balance'; -import { - getSignatoryOption, - getGeneralAccountOption, - validateBalanceForFee, - validateBalanceForFeeDeposit, -} from '../../common/utils'; - -export type ValidatorsResult = { - accounts: Account[]; - signer?: Account; - description?: string; -}; - -type Props = { - api: ApiPromise; - chainId: ChainId; - accounts: Account[]; - asset: Asset; - addressPrefix: number; - onResult: (data: ValidatorsResult) => void; -}; - -const InitOperation = ({ api, chainId, accounts, asset, addressPrefix, onResult }: Props) => { - const { t } = useI18n(); - const activeWallet = useUnit(walletModel.$activeWallet); - - const [fee, setFee] = useState(''); - const [feeLoading, setFeeLoading] = useState(true); - const [deposit, setDeposit] = useState(''); - - const [activeValidatorsAccounts, setActiveValidatorsAccounts] = useState<(Account | MultisigAccount)[]>([]); - - const [activeSignatory, setActiveSignatory] = useState(); - - const [transactions, setTransactions] = useState([]); - const [activeBalances, setActiveBalances] = useState([]); - - const firstAccount = activeValidatorsAccounts[0] || accounts[0]; - const isMultisigWallet = walletUtils.isMultisig(activeWallet); - const isMultisigAccount = firstAccount && accountUtils.isMultisigAccount(firstAccount); - const formFields = isMultisigWallet ? [{ name: 'description' }] : []; - - const accountIds = accounts.map((account) => account.accountId); - const balances = useAssetBalances({ - chainId, - accountIds, - assetId: asset.assetId.toString(), - }); - - const signatoryIds = isMultisigAccount ? firstAccount.signatories.map((s) => s.accountId) : []; - const signatoriesBalances = useAssetBalances({ - chainId, - accountIds: signatoryIds, - assetId: asset.assetId.toString(), - }); - const signerBalance = signatoriesBalances.find((b) => b.accountId === activeSignatory?.accountId); - - useEffect(() => { - if (accounts.length === 0) return; - - setActiveValidatorsAccounts(accounts); - }, [accounts.length]); - - useEffect(() => { - const balancesMap = new Map(balances.map((balance) => [balance.accountId, balance])); - const newActiveBalances = activeValidatorsAccounts - .map((a) => balancesMap.get(a.accountId)) - .filter(nonNullable) as AccountBalance[]; - - setActiveBalances(newActiveBalances); - }, [activeValidatorsAccounts.length, balances]); - - useEffect(() => { - if (isMultisigWallet) { - setActiveValidatorsAccounts(accounts); - } - }, [isMultisigWallet, firstAccount?.accountId]); - - useEffect(() => { - const maxValidators = validatorsService.getMaxValidators(api); - - const bondPayload = activeValidatorsAccounts.map(({ accountId }) => { - const address = toAddress(accountId, { prefix: addressPrefix }); - - return { - chainId, - address, - type: TransactionType.NOMINATE, - args: { targets: Array(maxValidators).fill(address) }, - }; - }); - - setTransactions(bondPayload); - }, [activeValidatorsAccounts.length]); - - const getAccountDropdownOption = (account: Account) => { - const balance = balances.find((b) => b.accountId === account.accountId); - - return getGeneralAccountOption(account, { asset, fee, balance, addressPrefix }); - }; - - const getSignatoryDropdownOption = (wallet: Wallet, account: Account) => { - const balance = signatoriesBalances.find((b) => b.accountId === account.accountId); - - return getSignatoryOption(wallet, account, { balance, asset, addressPrefix, fee, deposit }); - }; - - const submitBond = (data: { amount: string; destination?: string; description?: string }) => { - const selectedAccountIds = activeValidatorsAccounts.map((a) => a.accountId); - const selectedAccounts = accounts.filter((account) => selectedAccountIds.includes(account.accountId)); - - onResult({ - accounts: selectedAccounts, - ...(isMultisigWallet && { - description: data.description || t('transactionMessage.nominate'), - signer: activeSignatory, - }), - }); - }; - - const validateFee = (): boolean => { - if (!isMultisigWallet) { - return activeBalances.every((b) => validateBalanceForFee(b, fee)); - } - - if (!signerBalance) return false; - - return validateBalanceForFee(signerBalance, fee); - }; - - const validateDeposit = (): boolean => { - if (!isMultisigWallet) return true; - if (!signerBalance) return false; - - return validateBalanceForFeeDeposit(signerBalance, deposit, fee); - }; - - const getActiveAccounts = (): AccountId[] => { - if (!isMultisigWallet) return activeValidatorsAccounts.map((acc) => acc.accountId); - - return activeSignatory ? [activeSignatory.accountId] : []; - }; - - const isValidFee = validateFee(); - const isValidDeposit = validateDeposit(); - const errors = getOperationErrors(!isValidFee, !isValidDeposit); - const canSubmit = - !feeLoading && (activeValidatorsAccounts.length > 0 || Boolean(activeSignatory)) && isValidFee && isValidDeposit; - - return ( -
- - } - footer={ - - } - onSubmit={submitBond} - /> -
- ); -}; - -export default InitOperation; diff --git a/src/renderer/pages/Staking/Operations/Redeem/InitOperation/InitOperation.tsx b/src/renderer/pages/Staking/Operations/Redeem/InitOperation/InitOperation.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/renderer/pages/Staking/Operations/StakeMore/InitOperation/InitOperation.tsx b/src/renderer/pages/Staking/Operations/StakeMore/InitOperation/InitOperation.tsx deleted file mode 100644 index 7efa4121b5..0000000000 --- a/src/renderer/pages/Staking/Operations/StakeMore/InitOperation/InitOperation.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { ApiPromise } from '@polkadot/api'; -import { BN } from '@polkadot/util'; -import { useEffect, useState } from 'react'; -import { useUnit } from 'effector-react'; - -import { useI18n } from '@app/providers'; -import { Transaction, TransactionType, OperationError } from '@entities/transaction'; -import { formatAmount, stakeableAmount, nonNullable, toAddress } from '@shared/lib/utils'; -import { OperationFooter, OperationHeader } from '@features/operations'; -import { accountUtils, walletModel, walletUtils } from '@entities/wallet'; -import type { Account, Asset, Balance as AccountBalance, ChainId, AccountId, Balance, Wallet } from '@shared/core'; -import { OperationForm } from '../../components'; -import { - getStakeAccountOption, - validateBalanceForFee, - validateStake, - validateBalanceForFeeDeposit, - getSignatoryOption, -} from '../../common/utils'; -import { useAssetBalances } from '@entities/balance'; - -export type StakeMoreResult = { - accounts: Account[]; - amount: string; - signer?: Account; - description?: string; -}; - -type Props = { - api: ApiPromise; - accounts: Account[]; - chainId: ChainId; - addressPrefix: number; - asset: Asset; - onResult: (data: StakeMoreResult) => void; -}; - -const InitOperation = ({ api, chainId, accounts, addressPrefix, asset, onResult }: Props) => { - const { t } = useI18n(); - const activeWallet = useUnit(walletModel.$activeWallet); - - const [fee, setFee] = useState(''); - const [feeLoading, setFeeLoading] = useState(true); - const [deposit, setDeposit] = useState(''); - const [amount, setAmount] = useState(''); - - const [minBalance, setMinBalance] = useState('0'); - const [activeBalances, setActiveBalances] = useState([]); - - const [activeStakeMoreAccounts, setActiveStakeMoreAccounts] = useState([]); - const [activeSignatory, setActiveSignatory] = useState(); - - const [transactions, setTransactions] = useState([]); - - const firstAccount = activeStakeMoreAccounts[0] || accounts[0]; - const isMultisigWallet = walletUtils.isMultisig(activeWallet); - const isMultisigAccount = firstAccount && accountUtils.isMultisigAccount(firstAccount); - const formFields = isMultisigWallet ? [{ name: 'amount' }, { name: 'description' }] : [{ name: 'amount' }]; - - const accountIds = accounts.map((account) => account.accountId); - const balances = useAssetBalances({ - accountIds, - chainId, - assetId: asset.assetId.toString(), - }); - - const signatoryIds = isMultisigAccount ? firstAccount.signatories.map((s) => s.accountId) : []; - const signatoriesBalances = useAssetBalances({ - accountIds: signatoryIds, - chainId, - assetId: asset.assetId.toString(), - }); - const signerBalance = signatoriesBalances.find((b) => b.accountId === activeSignatory?.accountId); - - useEffect(() => { - if (accounts.length === 0) return; - - setActiveStakeMoreAccounts(accounts); - }, [accounts.length]); - - useEffect(() => { - const balancesMap = new Map(balances.map((balance) => [balance.accountId, balance])); - const newActiveBalances = activeStakeMoreAccounts - .map((a) => balancesMap.get(a.accountId)) - .filter(nonNullable) as AccountBalance[]; - - setActiveBalances(newActiveBalances); - }, [activeStakeMoreAccounts.length, balances]); - - useEffect(() => { - if (isMultisigWallet || activeBalances.length === 1) { - setMinBalance(stakeableAmount(activeBalances[0])); - - return; - } - - if (!activeBalances.length) { - setMinBalance('0'); - } else { - const stakeableBalance = activeBalances.map(stakeableAmount).filter((balance) => balance && balance !== '0'); - const minBalance = stakeableBalance.reduce( - (acc, balance) => (new BN(balance).lt(new BN(acc)) ? balance : acc), - stakeableBalance[0], - ); - - setMinBalance(minBalance); - } - }, [activeBalances.length]); - - useEffect(() => { - if (!minBalance) return; - - const newTransactions = activeStakeMoreAccounts.map(({ accountId }) => { - return { - chainId, - type: TransactionType.STAKE_MORE, - address: toAddress(accountId, { prefix: addressPrefix }), - args: { maxAdditional: formatAmount(amount, asset.precision) }, - }; - }); - - setTransactions(newTransactions); - }, [minBalance, amount]); - - const getAccountDropdownOption = (account: Account) => { - const balance = balances.find((b) => b.accountId === account.accountId); - - return getStakeAccountOption(account, { balance, asset, fee, addressPrefix, amount }); - }; - - const getSignatoryDropdownOption = (wallet: Wallet, account: Account) => { - const balance = signatoriesBalances.find((b) => b.accountId === account.accountId); - - return getSignatoryOption(wallet, account, { balance, asset, addressPrefix, fee, deposit }); - }; - - const submitStakeMore = (data: { amount: string; description?: string }) => { - const selectedAccountIds = activeStakeMoreAccounts.map((stake) => stake.accountId); - const selectedAccounts = accounts.filter((account) => selectedAccountIds.includes(account.accountId)); - - onResult({ - accounts: selectedAccounts, - amount: formatAmount(data.amount, asset.precision), - ...(isMultisigWallet && { - description: - data.description || t('transactionMessage.stakeMore', { amount: data.amount, asset: asset.symbol }), - signer: activeSignatory, - }), - }); - }; - - const validateBalance = (amount: string): boolean => { - return activeBalances.every((b) => validateStake(b, amount, asset.precision)); - }; - - const validateFee = (amount: string): boolean => { - if (!isMultisigWallet) { - return activeBalances.every((b) => validateStake(b, amount, asset.precision, fee)); - } - - if (!signerBalance) return false; - - return validateBalanceForFee(signerBalance, fee); - }; - - const validateDeposit = (): boolean => { - if (!isMultisigWallet) return true; - if (!signerBalance) return false; - - return validateBalanceForFeeDeposit(signerBalance, deposit, fee); - }; - - const getBalanceRange = (): string | string[] => { - if (activeSignatory) return minBalance; - - return activeBalances.length > 1 ? ['0', minBalance] : minBalance; - }; - - const getActiveAccounts = (): AccountId[] => { - if (!isMultisigWallet) return activeStakeMoreAccounts.map((acc) => acc.accountId); - - return activeSignatory ? [activeSignatory.accountId] : []; - }; - - const canSubmit = !feeLoading && (activeStakeMoreAccounts.length > 0 || Boolean(activeSignatory)); - - return ( -
- ( - - )} - footer={ - - } - onSubmit={submitStakeMore} - onAmountChange={setAmount} - /> -
- ); -}; - -export default InitOperation; diff --git a/src/renderer/pages/Staking/Operations/StakeMore/StakeMore.tsx b/src/renderer/pages/Staking/Operations/StakeMore/StakeMore.tsx deleted file mode 100644 index 6baae6e989..0000000000 --- a/src/renderer/pages/Staking/Operations/StakeMore/StakeMore.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { UnsignedTransaction } from '@substrate/txwrapper-polkadot'; -import { useEffect, useState } from 'react'; -import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { useUnit } from 'effector-react'; - -import { useI18n } from '@app/providers'; -import { Paths } from '@shared/routes'; -import { Transaction, TransactionType, useTransaction } from '@entities/transaction'; -import InitOperation, { StakeMoreResult } from './InitOperation/InitOperation'; -import { Confirmation, NoAsset, Submit } from '../components'; -import { DEFAULT_TRANSITION, getRelaychainAsset, toAddress } from '@shared/lib/utils'; -import { useToggle } from '@shared/lib/hooks'; -import { Account, ChainId, HexString, MultisigAccount } from '@shared/core'; -import { BaseModal, Button, Loader } from '@shared/ui'; -import { OperationTitle } from '@entities/chain'; -import { SigningSwitch } from '@features/operations'; -import { walletModel, walletUtils } from '@entities/wallet'; -import { priceProviderModel } from '@entities/price'; -import { useNetworkData } from '@entities/network'; -import { StakingPopover } from '@entities/staking'; - -const enum Step { - INIT, - CONFIRMATION, - SIGNING, - SUBMIT, -} - -export const StakeMore = () => { - const { t } = useI18n(); - const activeWallet = useUnit(walletModel.$activeWallet); - const activeAccounts = useUnit(walletModel.$activeAccounts); - - const navigate = useNavigate(); - const { setTxs, txs, setWrappers, wrapTx, buildTransaction } = useTransaction(); - const [searchParams] = useSearchParams(); - const params = useParams<{ chainId: ChainId }>(); - - const { api, chain } = useNetworkData(params.chainId); - - const [isStakeMoreModalOpen, toggleStakeMoreModal] = useToggle(true); - - const [activeStep, setActiveStep] = useState(Step.INIT); - - const [stakeMoreAmount, setStakeMoreAmount] = useState(''); - const [description, setDescription] = useState(''); - - const [unsignedTransactions, setUnsignedTransactions] = useState([]); - - const [accounts, setAccounts] = useState([]); - const [txAccounts, setTxAccounts] = useState([]); - const [signer, setSigner] = useState(); - const [signatures, setSignatures] = useState([]); - - const isMultisigWallet = walletUtils.isMultisig(activeWallet); - - const accountIds = searchParams.get('id')?.split(',') || []; - const chainId = params.chainId || ('' as ChainId); - - useEffect(() => { - priceProviderModel.events.assetsPricesRequested({ includeRates: true }); - }, []); - - useEffect(() => { - if (!activeAccounts.length || !accountIds.length) return; - - const accounts = activeAccounts.filter((a) => a.id && accountIds.includes(a.id.toString())); - setAccounts(accounts); - }, [activeAccounts.length]); - - if (!api || accountIds.length === 0) { - return ; - } - - const { explorers, addressPrefix, assets, name } = chain; - const asset = getRelaychainAsset(assets); - - const goToPrevStep = () => { - if (activeStep === Step.INIT) { - navigate(Paths.STAKING); - } else { - setActiveStep((prev) => prev - 1); - } - }; - - const closeStakeMoreModal = () => { - toggleStakeMoreModal(); - setTimeout(() => navigate(Paths.STAKING), DEFAULT_TRANSITION); - }; - - if (!api?.isConnected) { - return ( - } - onClose={closeStakeMoreModal} - > -
- - -
-
- ); - } - - if (!asset) { - return ( - } - onClose={closeStakeMoreModal} - > -
- -
-
- ); - } - - const getStakeMoreTxs = (accounts: Account[], amount: string): Transaction[] => { - return accounts.map(({ accountId }) => - buildTransaction(TransactionType.STAKE_MORE, toAddress(accountId, { prefix: addressPrefix }), chainId, { - maxAdditional: amount, - }), - ); - }; - - const onInitResult = ({ accounts, amount, signer, description }: StakeMoreResult) => { - const transactions = getStakeMoreTxs(accounts, amount); - - if (signer && isMultisigWallet) { - setWrappers([ - { - signatoryId: signer.accountId, - account: accounts[0] as MultisigAccount, - }, - ]); - setSigner(signer); - setDescription(description || ''); - } - - setTxs(transactions); - setTxAccounts(accounts); - setStakeMoreAmount(amount); - setActiveStep(Step.CONFIRMATION); - }; - - const onSignResult = (signatures: HexString[], unsigned: UnsignedTransaction[]) => { - setUnsignedTransactions(unsigned); - setSignatures(signatures); - setActiveStep(Step.SUBMIT); - }; - - const explorersProps = { explorers, addressPrefix, asset }; - const stakeMoreValues = new Array(txAccounts.length).fill(stakeMoreAmount); - const multisigTx = isMultisigWallet ? wrapTx(txs[0], api, addressPrefix) : undefined; - - return ( - <> - } - onClose={closeStakeMoreModal} - > - {activeStep === Step.INIT && ( - - )} - {activeStep === Step.CONFIRMATION && ( - setActiveStep(Step.SIGNING)} - onGoBack={goToPrevStep} - {...explorersProps} - > - - {t('staking.confirmation.hintNewRewards')} - - - )} - {activeStep === Step.SIGNING && ( - setActiveStep(Step.CONFIRMATION)} - onResult={onSignResult} - /> - )} - - - {activeStep === Step.SUBMIT && ( - - )} - - ); -}; diff --git a/src/renderer/pages/Staking/Overview/Overview.tsx b/src/renderer/pages/Staking/Overview/Overview.tsx index 513edd08d6..19ca9b8ddb 100644 --- a/src/renderer/pages/Staking/Overview/Overview.tsx +++ b/src/renderer/pages/Staking/Overview/Overview.tsx @@ -17,7 +17,8 @@ import { ChainId, Chain, Address, Account, Stake, Validator, ShardAccount, Chain import { BondNominate, bondNominateModel } from '@widgets/Staking/BondNominate'; import { BondExtra, bondExtraModel } from '@widgets/Staking/BondExtra'; import { Unstake, unstakeModel } from '@widgets/Staking/Unstake'; -import { Withdraw, withdrawModel } from '@widgets/Withdraw'; +import { Nominate, nominateModel } from '@widgets/Staking/Nominate'; +import { Withdraw, withdrawModel } from '@widgets/Staking/Withdraw'; import { NominatorInfo } from './common/types'; import { useStakingData, @@ -228,7 +229,13 @@ export const Overview = () => { return; } - if (path === Paths.BOND || path === Paths.STAKE_MORE || path === Paths.UNSTAKE || path === Paths.REDEEM) { + if ( + path === Paths.BOND || + path === Paths.STAKE_MORE || + path === Paths.UNSTAKE || + path === Paths.VALIDATORS || + path === Paths.REDEEM + ) { const shards = accounts.filter((account) => { const address = toAddress(account.accountId, { prefix: addressPrefix }); @@ -239,6 +246,7 @@ export const Overview = () => { [Paths.BOND]: bondNominateModel.events.flowStarted, [Paths.STAKE_MORE]: bondExtraModel.events.flowStarted, [Paths.UNSTAKE]: unstakeModel.events.flowStarted, + [Paths.VALIDATORS]: nominateModel.events.flowStarted, [Paths.REDEEM]: withdrawModel.events.flowStarted, }; @@ -337,6 +345,7 @@ export const Overview = () => { + diff --git a/src/renderer/pages/Staking/Overview/components/AboutStaking/AboutStaking.tsx b/src/renderer/pages/Staking/Overview/components/AboutStaking/AboutStaking.tsx index b2fbbdcb10..5cdcf8e337 100644 --- a/src/renderer/pages/Staking/Overview/components/AboutStaking/AboutStaking.tsx +++ b/src/renderer/pages/Staking/Overview/components/AboutStaking/AboutStaking.tsx @@ -106,15 +106,15 @@ export const AboutStaking = ({ api, era, asset, validators }: Props) => { {t('staking.about.totalStakedLabel')}
{totalStaked && asset ? ( - <> +
- +
) : ( - <> - - - +
+ + +
)}
@@ -123,15 +123,15 @@ export const AboutStaking = ({ api, era, asset, validators }: Props) => { {t('staking.about.minimumStakeLabel')}
{minimumStake && asset ? ( - <> +
- +
) : ( - <> - - - +
+ + +
)}
diff --git a/src/renderer/pages/Staking/index.ts b/src/renderer/pages/Staking/index.ts index e9612e004c..7f64b5231e 100644 --- a/src/renderer/pages/Staking/index.ts +++ b/src/renderer/pages/Staking/index.ts @@ -1,5 +1,3 @@ export { Overview } from './Overview/Overview'; export { Destination } from './Operations/Destination/Destination'; export { Restake } from './Operations/Restake/Restake'; -export { StakeMore } from './Operations/StakeMore/StakeMore'; -export { ChangeValidators } from './Operations/ChangeValidators/ChangeValidators'; diff --git a/src/renderer/pages/index.tsx b/src/renderer/pages/index.tsx index 91fc394385..4566e6bc6f 100644 --- a/src/renderer/pages/index.tsx +++ b/src/renderer/pages/index.tsx @@ -8,7 +8,7 @@ import { Operations } from './Operations/Operations'; import { Notifications } from './Notifications/Notifications'; import { Contacts, CreateContact, EditContact } from './AddressBook'; import { Overview as Settings, Matrix, Currency, Networks } from './Settings'; -import { Overview as Staking, ChangeValidators, Restake, Destination } from './Staking'; +import { Overview as Staking, Restake, Destination } from './Staking'; // React routes v6 hint: // https://github.com/remix-run/react-router/blob/main/docs/upgrading/v5.md#use-useroutes-instead-of-react-router-config @@ -52,7 +52,6 @@ export const ROUTES_CONFIG: RouteObject[] = [ children: [ { path: Paths.RESTAKE, element: }, { path: Paths.DESTINATION, element: }, - { path: Paths.VALIDATORS, element: }, ], }, ], diff --git a/src/renderer/widgets/AddProxyModal/model/__tests__/add-proxy-model.test.ts b/src/renderer/widgets/AddProxyModal/model/__tests__/add-proxy-model.test.ts index 82bc1de481..977a288e02 100644 --- a/src/renderer/widgets/AddProxyModal/model/__tests__/add-proxy-model.test.ts +++ b/src/renderer/widgets/AddProxyModal/model/__tests__/add-proxy-model.test.ts @@ -54,13 +54,15 @@ describe('widgets/AddProxyModal/model/add-proxy-model', () => { coreTx: {} as Transaction, }, formData: { - proxyDeposit: '1', - proxyNumber: 1, chain: testChain, account: { accountId: '0x00' } as unknown as Account, delegate: '0x00', proxyType: ProxyType.ANY, description: '', + proxyDeposit: '1', + proxyNumber: 1, + fee: '1', + multisigDeposit: '0', }, }, }); diff --git a/src/renderer/widgets/AddProxyModal/model/__tests__/confirm-model.test.ts b/src/renderer/widgets/AddProxyModal/model/__tests__/confirm-model.test.ts index 33530ea03b..1543a9c90b 100644 --- a/src/renderer/widgets/AddProxyModal/model/__tests__/confirm-model.test.ts +++ b/src/renderer/widgets/AddProxyModal/model/__tests__/confirm-model.test.ts @@ -5,6 +5,7 @@ import { networkModel } from '@entities/network'; import { walletModel } from '@entities/wallet'; import { Account, Chain, ProxyType } from '@shared/core'; import { Transaction } from '@entities/transaction'; +import { TEST_ADDRESS } from '@shared/lib/utils'; import { initiatorWallet, signerWallet, testApi } from './mock'; describe('widgets/AddPureProxyModal/model/confirm-model', () => { @@ -25,10 +26,10 @@ describe('widgets/AddPureProxyModal/model/confirm-model', () => { signatory: { walletId: 2 } as unknown as Account, description: '', transaction: {} as Transaction, - delegate: '0x01', + delegate: TEST_ADDRESS, proxyType: ProxyType.ANY, - oldProxyDeposit: '0', + proxyDeposit: '0', proxyNumber: 0, }; @@ -48,10 +49,12 @@ describe('widgets/AddPureProxyModal/model/confirm-model', () => { const store = { chain: { chainId: '0x00' } as unknown as Chain, account: { walletId: 1 } as unknown as Account, - description: '', transaction: {} as Transaction, + proxyType: ProxyType.GOVERNANCE, + delegate: TEST_ADDRESS, + description: '', - oldProxyDeposit: '0', + proxyDeposit: '0', proxyNumber: 0, }; diff --git a/src/renderer/widgets/Staking/BondExtra/lib/bond-extra-utils.ts b/src/renderer/widgets/Staking/BondExtra/lib/bond-extra-utils.ts index b368424451..35dce6beb9 100644 --- a/src/renderer/widgets/Staking/BondExtra/lib/bond-extra-utils.ts +++ b/src/renderer/widgets/Staking/BondExtra/lib/bond-extra-utils.ts @@ -1,8 +1,8 @@ -import { Step } from './types'; import { walletUtils, accountUtils } from '@entities/wallet'; import { dictionary } from '@shared/lib/utils'; import { transactionService } from '@entities/transaction'; import { Wallet, Account, Chain } from '@shared/core'; +import { Step } from './types'; export const bondExtraUtils = { isNoneStep, diff --git a/src/renderer/widgets/Staking/BondNominate/lib/bond-utils.ts b/src/renderer/widgets/Staking/BondNominate/lib/bond-utils.ts index 0915e37875..2731adab41 100644 --- a/src/renderer/widgets/Staking/BondNominate/lib/bond-utils.ts +++ b/src/renderer/widgets/Staking/BondNominate/lib/bond-utils.ts @@ -1,8 +1,8 @@ -import { Step } from './types'; import { walletUtils, accountUtils } from '@entities/wallet'; import { dictionary } from '@shared/lib/utils'; import { transactionService } from '@entities/transaction'; import { Wallet, Account, Chain } from '@shared/core'; +import { Step } from './types'; export const bondUtils = { isNoneStep, diff --git a/src/renderer/widgets/Staking/Nominate/index.ts b/src/renderer/widgets/Staking/Nominate/index.ts new file mode 100644 index 0000000000..514086277f --- /dev/null +++ b/src/renderer/widgets/Staking/Nominate/index.ts @@ -0,0 +1,2 @@ +export { Nominate } from './ui/Nominate'; +export { nominateModel } from './model/nominate-model'; diff --git a/src/renderer/widgets/Staking/Nominate/lib/nominate-utils.ts b/src/renderer/widgets/Staking/Nominate/lib/nominate-utils.ts new file mode 100644 index 0000000000..e4b815082c --- /dev/null +++ b/src/renderer/widgets/Staking/Nominate/lib/nominate-utils.ts @@ -0,0 +1,70 @@ +import { walletUtils, accountUtils } from '@entities/wallet'; +import { dictionary } from '@shared/lib/utils'; +import { transactionService } from '@entities/transaction'; +import { Wallet, Account, Chain } from '@shared/core'; +import { Step } from './types'; + +export const nominateUtils = { + isNoneStep, + isInitStep, + isValidatorsStep, + isConfirmStep, + isSignStep, + isSubmitStep, + + getTxWrappers, +}; + +function isNoneStep(step: Step): boolean { + return step === Step.NONE; +} + +function isInitStep(step: Step): boolean { + return step === Step.INIT; +} + +function isValidatorsStep(step: Step): boolean { + return step === Step.VALIDATORS; +} + +function isConfirmStep(step: Step): boolean { + return step === Step.CONFIRM; +} + +function isSignStep(step: Step): boolean { + return step === Step.SIGN; +} + +function isSubmitStep(step: Step): boolean { + return step === Step.SUBMIT; +} + +type TxWrapperParams = { + chain: Chain; + wallet: Wallet; + wallets: Wallet[]; + account: Account; + accounts: Account[]; + signatories: Account[]; +}; +function getTxWrappers({ chain, wallet, wallets, account, accounts, signatories }: TxWrapperParams) { + const walletFiltered = wallets.filter((wallet) => { + return !walletUtils.isProxied(wallet) && !walletUtils.isWatchOnly(wallet); + }); + const walletsMap = dictionary(walletFiltered, 'id'); + const chainFilteredAccounts = accounts.filter((account) => { + if (accountUtils.isBaseAccount(account) && walletUtils.isPolkadotVault(walletsMap[account.walletId])) { + return false; + } + + return accountUtils.isChainAndCryptoMatch(account, chain); + }); + + return transactionService.getTxWrappers({ + wallet, + wallets: walletFiltered, + account, + accounts: chainFilteredAccounts, + signatories, + }); +} diff --git a/src/renderer/widgets/Unstake/lib/types.ts b/src/renderer/widgets/Staking/Nominate/lib/types.ts similarity index 53% rename from src/renderer/widgets/Unstake/lib/types.ts rename to src/renderer/widgets/Staking/Nominate/lib/types.ts index c26d0c039c..19f8dbfe10 100644 --- a/src/renderer/widgets/Unstake/lib/types.ts +++ b/src/renderer/widgets/Staking/Nominate/lib/types.ts @@ -1,25 +1,28 @@ -import type { Account, Chain, ProxiedAccount } from '@shared/core'; +import type { Account, Chain, Wallet, Validator } from '@shared/core'; export const enum Step { NONE, INIT, + VALIDATORS, CONFIRM, SIGN, SUBMIT, } -export type NetworkStore = { - chain: Chain; +export type WalletData = { + wallet: Wallet; shards: Account[]; + chain: Chain; }; -export type UnstakeStore = { +export type NominateData = { shards: Account[]; - proxiedAccount?: ProxiedAccount; signatory?: Account; - amount: string; + validators: Validator[]; description: string; +}; +export type FeeData = { fee: string; totalFee: string; multisigDeposit: string; diff --git a/src/renderer/widgets/Withdraw/model/confirm-model.ts b/src/renderer/widgets/Staking/Nominate/model/confirm-model.ts similarity index 72% rename from src/renderer/widgets/Withdraw/model/confirm-model.ts rename to src/renderer/widgets/Staking/Nominate/model/confirm-model.ts index f2eee14443..a9e0b9d1b0 100644 --- a/src/renderer/widgets/Withdraw/model/confirm-model.ts +++ b/src/renderer/widgets/Staking/Nominate/model/confirm-model.ts @@ -1,37 +1,29 @@ -import { createEvent, combine, sample, restore } from 'effector'; +import { createEvent, combine, restore } from 'effector'; -import { Chain, Account, Asset, type ProxiedAccount } from '@shared/core'; -import { networkModel } from '@entities/network'; +import { Chain, Account, Asset, type ProxiedAccount, Validator } from '@shared/core'; import { walletModel, walletUtils } from '@entities/wallet'; type Input = { chain: Chain; asset: Asset; + shards: Account[]; + validators: Validator[]; proxiedAccount?: ProxiedAccount; signatory?: Account; - amount: string; description: string; - - fee: string; - totalFee: string; - multisigDeposit: string; }; const formInitiated = createEvent(); const formSubmitted = createEvent(); +const feeDataChanged = createEvent>(); +const isFeeLoadingChanged = createEvent(); + const $confirmStore = restore(formInitiated, null); -const $api = combine( - { - apis: networkModel.$apis, - store: $confirmStore, - }, - ({ apis, store }) => { - return store ? apis[store.chain.chainId] : null; - }, -); +const $feeData = restore(feeDataChanged, { fee: '0', totalFee: '0', multisigDeposit: '0' }); +const $isFeeLoading = restore(isFeeLoadingChanged, true); const $initiatorWallet = combine( { @@ -72,20 +64,19 @@ const $signerWallet = combine( { skipVoid: false }, ); -sample({ - clock: formInitiated, - target: $confirmStore, -}); - export const confirmModel = { $confirmStore, $initiatorWallet, $proxiedWallet, $signerWallet, - $api, + $feeData, + $isFeeLoading, + events: { formInitiated, + feeDataChanged, + isFeeLoadingChanged, }, output: { formSubmitted, diff --git a/src/renderer/widgets/Staking/Nominate/model/form-model.ts b/src/renderer/widgets/Staking/Nominate/model/form-model.ts new file mode 100644 index 0000000000..5786eef535 --- /dev/null +++ b/src/renderer/widgets/Staking/Nominate/model/form-model.ts @@ -0,0 +1,354 @@ +import { createEvent, createStore, combine, sample, restore } from 'effector'; +import { spread } from 'patronum'; +import { createForm } from 'effector-forms'; +import { BN } from '@polkadot/util'; + +import { walletModel, walletUtils } from '@entities/wallet'; +import { balanceModel, balanceUtils } from '@entities/balance'; +import { networkModel } from '@entities/network'; +import { Account, PartialBy, Chain, Asset } from '@shared/core'; +import { WalletData } from '../lib/types'; +import { transferableAmount, getRelaychainAsset, formatAmount, stakeableAmount, ZERO_BALANCE } from '@shared/lib/utils'; + +type FormParams = { + shards: Account[]; + signatory: Account; + description: string; +}; + +const formInitiated = createEvent(); +const formSubmitted = createEvent(); +const formChanged = createEvent>(); +const formCleared = createEvent(); + +const txWrapperChanged = createEvent<{ + proxyAccount: Account | null; + signatories: Account[][]; + isProxy: boolean; + isMultisig: boolean; +}>(); +const feeDataChanged = createEvent>(); +const isFeeLoadingChanged = createEvent(); + +const $shards = createStore([]); +const $networkStore = createStore<{ chain: Chain; asset: Asset } | null>(null); + +const $accountsBalances = createStore([]); +const $signatoryBalance = createStore(ZERO_BALANCE); +const $proxyBalance = createStore(ZERO_BALANCE); + +const $availableSignatories = createStore([]); +const $proxyAccount = createStore(null); +const $isProxy = createStore(false); +const $isMultisig = createStore(false); + +const $feeData = restore(feeDataChanged, { fee: ZERO_BALANCE, totalFee: ZERO_BALANCE, multisigDeposit: ZERO_BALANCE }); +const $isFeeLoading = restore(isFeeLoadingChanged, true); + +const $bondForm = createForm({ + fields: { + shards: { + init: [] as Account[], + rules: [ + { + name: 'noProxyFee', + source: combine({ + feeData: $feeData, + isProxy: $isProxy, + proxyBalance: $proxyBalance, + }), + validator: (_s, _f, { isProxy, proxyBalance, feeData }) => { + if (!isProxy) return true; + + return new BN(feeData.fee).lte(new BN(proxyBalance)); + }, + }, + { + name: 'noBondBalance', + errorText: 'staking.bond.noBondBalanceError', + source: combine({ + isProxy: $isProxy, + network: $networkStore, + accountsBalances: $accountsBalances, + }), + validator: (shards, form, { isProxy, network, accountsBalances }) => { + if (isProxy || shards.length === 1) return true; + + const amountBN = new BN(formatAmount(form.amount, network.asset.precision)); + + return shards.every((_, index) => amountBN.lte(new BN(accountsBalances[index]))); + }, + }, + ], + }, + signatory: { + init: {} as Account, + rules: [ + { + name: 'noSignatorySelected', + errorText: 'transfer.noSignatoryError', + source: $isMultisig, + validator: (signatory, _, isMultisig) => { + if (!isMultisig) return true; + + return Object.keys(signatory).length > 0; + }, + }, + { + name: 'notEnoughTokens', + errorText: 'proxy.addProxy.notEnoughMultisigTokens', + source: combine({ + feeData: $feeData, + isMultisig: $isMultisig, + signatoryBalance: $signatoryBalance, + }), + validator: (_s, _f, { feeData, isMultisig, signatoryBalance }) => { + if (!isMultisig) return true; + + return new BN(feeData.multisigDeposit).add(new BN(feeData.fee)).lte(new BN(signatoryBalance)); + }, + }, + ], + }, + description: { + init: '', + rules: [ + { + name: 'maxLength', + errorText: 'transfer.descriptionLengthError', + validator: (value) => !value || value.length <= 120, + }, + ], + }, + }, + validateOn: ['submit'], +}); + +// Computed + +const $proxyWallet = combine( + { + isProxy: $isProxy, + proxyAccount: $proxyAccount, + wallets: walletModel.$wallets, + }, + ({ isProxy, proxyAccount, wallets }) => { + if (!isProxy || !proxyAccount) return undefined; + + return walletUtils.getWalletById(wallets, proxyAccount.walletId); + }, + { skipVoid: false }, +); + +const $accounts = combine( + { + network: $networkStore, + wallet: walletModel.$activeWallet, + shards: $shards, + balances: balanceModel.$balances, + }, + ({ network, wallet, shards, balances }) => { + if (!wallet || !network) return []; + + const { chain, asset } = network; + + return shards.map((shard) => { + const balance = balanceUtils.getBalance(balances, shard.accountId, chain.chainId, asset.assetId.toString()); + + return { account: shard, balance: stakeableAmount(balance) }; + }); + }, +); + +const $signatories = combine( + { + network: $networkStore, + availableSignatories: $availableSignatories, + balances: balanceModel.$balances, + }, + ({ network, availableSignatories, balances }) => { + if (!network) return []; + + const { chain, asset } = network; + + return availableSignatories.reduce>((acc, signatories) => { + const balancedSignatories = signatories.map((signatory) => { + const balance = balanceUtils.getBalance(balances, signatory.accountId, chain.chainId, asset.assetId.toString()); + + return { signer: signatory, balance: transferableAmount(balance) }; + }); + + acc.push(balancedSignatories); + + return acc; + }, []); + }, +); + +const $api = combine( + { + apis: networkModel.$apis, + network: $networkStore, + }, + ({ apis, network }) => { + if (!network) return undefined; + + return apis[network.chain.chainId]; + }, + { skipVoid: false }, +); + +const $canSubmit = combine( + { + isFormValid: $bondForm.$isValid, + isFeeLoading: $isFeeLoading, + }, + ({ isFormValid, isFeeLoading }) => { + return isFormValid && !isFeeLoading; + }, +); + +// Fields connections + +sample({ + clock: formInitiated, + target: $bondForm.reset, +}); + +sample({ + clock: formInitiated, + filter: ({ chain, shards }) => Boolean(getRelaychainAsset(chain.assets)) && shards.length > 0, + fn: ({ chain, shards }) => ({ + shards, + networkStore: { chain, asset: getRelaychainAsset(chain.assets)! }, + }), + target: spread({ + shards: $shards, + networkStore: $networkStore, + }), +}); + +sample({ + clock: formInitiated, + source: $shards, + filter: (shards) => shards.length > 0, + fn: (shards) => shards, + target: $bondForm.fields.shards.onChange, +}); + +sample({ + clock: txWrapperChanged, + target: spread({ + isProxy: $isProxy, + isMultisig: $isMultisig, + signatories: $availableSignatories, + proxyAccount: $proxyAccount, + }), +}); + +sample({ + source: { + accounts: $accounts, + shards: $bondForm.fields.shards.$value, + }, + fn: ({ accounts, shards }) => { + return accounts.reduce((acc, { account, balance }) => { + if (shards.includes(account)) acc.push(balance); + + return acc; + }, []); + }, + target: $accountsBalances, +}); + +sample({ + clock: $bondForm.fields.signatory.onChange, + source: $signatories, + filter: (signatories) => signatories.length > 0, + fn: (signatories, signatory) => { + const match = signatories[0].find(({ signer }) => signer.id === signatory.id); + + return match?.balance || ZERO_BALANCE; + }, + target: $signatoryBalance, +}); + +sample({ + source: { + isProxy: $isProxy, + proxyAccount: $proxyAccount, + balances: balanceModel.$balances, + network: $networkStore, + }, + filter: ({ isProxy, network, proxyAccount }) => { + return isProxy && Boolean(network) && Boolean(proxyAccount); + }, + fn: ({ balances, network, proxyAccount }) => { + const balance = balanceUtils.getBalance( + balances, + proxyAccount!.accountId, + network!.chain.chainId, + network!.asset.assetId.toString(), + ); + + return transferableAmount(balance); + }, + target: $proxyBalance, +}); + +// Submit + +sample({ + clock: $bondForm.$values.updates, + source: $networkStore, + filter: (networkStore) => Boolean(networkStore), + fn: (networkStore, formData) => { + const signatory = formData.signatory.accountId ? formData.signatory : undefined; + // TODO: update after i18n effector integration + const description = signatory ? formData.description || 'Change nominators' : ''; + + return { ...formData, signatory, description }; + }, + target: formChanged, +}); + +sample({ + clock: $bondForm.formValidated, + target: formSubmitted, +}); + +sample({ + clock: formCleared, + target: [$bondForm.reset, $shards.reinit], +}); + +export const formModel = { + $bondForm, + $proxyWallet, + $signatories, + + $accounts, + $accountsBalances, + $proxyBalance, + + $feeData, + $isFeeLoading, + + $api, + $networkStore, + $isMultisig, + $canSubmit, + + events: { + formInitiated, + formCleared, + + txWrapperChanged, + feeDataChanged, + isFeeLoadingChanged, + }, + output: { + formSubmitted, + formChanged, + }, +}; diff --git a/src/renderer/widgets/Staking/Nominate/model/nominate-model.ts b/src/renderer/widgets/Staking/Nominate/model/nominate-model.ts new file mode 100644 index 0000000000..1794edc010 --- /dev/null +++ b/src/renderer/widgets/Staking/Nominate/model/nominate-model.ts @@ -0,0 +1,389 @@ +import { createEvent, createStore, sample, restore, combine, createEffect } from 'effector'; +import { ApiPromise } from '@polkadot/api'; +import { spread, delay } from 'patronum'; +import { BN } from '@polkadot/util'; + +import { walletModel } from '@entities/wallet'; +import { TEST_ADDRESS, getRelaychainAsset, nonNullable } from '@shared/lib/utils'; +import { networkModel } from '@entities/network'; +import { validatorsService } from '@entities/staking'; +import { submitModel } from '@features/operations/OperationSubmit'; +import { signModel } from '@features/operations/OperationSign/model/sign-model'; +import { Account } from '@shared/core'; +import { Step, NominateData, WalletData, FeeData } from '../lib/types'; +import { nominateUtils } from '../lib/nominate-utils'; +import { formModel } from './form-model'; +import { validatorsModel } from './validators-model'; +import { confirmModel } from './confirm-model'; +import { + TxWrapper, + Transaction, + transactionBuilder, + transactionService, + WrapperKind, + MultisigTxWrapper, + ProxyTxWrapper, +} from '@entities/transaction'; + +const stepChanged = createEvent(); + +const flowStarted = createEvent(); +const flowFinished = createEvent(); + +const $step = createStore(Step.NONE); + +const $walletData = restore(flowStarted, null); +const $nominateData = createStore(null); +const $feeData = createStore({ fee: '0', totalFee: '0', multisigDeposit: '0' }); + +const $txWrappers = createStore([]); +const $pureTxs = createStore([]); + +const $maxValidators = createStore(0); + +const getMaxValidatorsFx = createEffect((api: ApiPromise): number => { + return validatorsService.getMaxValidators(api); +}); + +type FeeParams = { + api: ApiPromise; + transaction: Transaction; +}; +const getTransactionFeeFx = createEffect(({ api, transaction }: FeeParams): Promise => { + return transactionService.getTransactionFee(transaction, api); +}); + +type DepositParams = { + api: ApiPromise; + threshold: number; +}; +const getMultisigDepositFx = createEffect(({ api, threshold }: DepositParams): string => { + return transactionService.getMultisigDeposit(threshold, api); +}); + +const $api = combine( + { + apis: networkModel.$apis, + walletData: $walletData, + }, + ({ apis, walletData }) => { + if (!walletData) return undefined; + + return apis[walletData.chain.chainId]; + }, + { skipVoid: false }, +); + +const $transactions = combine( + { + api: $api, + walletData: $walletData, + pureTxs: $pureTxs, + txWrappers: $txWrappers, + }, + ({ api, walletData, pureTxs, txWrappers }) => { + if (!api || !walletData) return undefined; + + return pureTxs.map((tx) => + transactionService.getWrappedTransaction({ + api, + addressPrefix: walletData.chain.addressPrefix, + transaction: tx, + txWrappers, + }), + ); + }, + { skipVoid: false }, +); + +// Max validators + +sample({ + clock: $api.updates, + source: $maxValidators, + filter: (maxValidators, api) => !maxValidators && Boolean(api), + fn: (_, api) => api!, + target: getMaxValidatorsFx, +}); + +sample({ + clock: getMaxValidatorsFx.doneData, + target: $maxValidators, +}); + +// Transaction & Form + +sample({ + clock: [flowStarted, formModel.output.formChanged], + source: { + walletData: $walletData, + wallets: walletModel.$wallets, + accounts: walletModel.$accounts, + }, + filter: ({ walletData }) => Boolean(walletData), + fn: ({ walletData, wallets, accounts }, data) => { + const signatories = 'signatory' in data && data.signatory ? [data.signatory] : []; + + return nominateUtils.getTxWrappers({ + chain: walletData!.chain, + wallet: walletData!.wallet, + wallets, + account: walletData!.shards[0], + accounts, + signatories, + }); + }, + target: $txWrappers, +}); + +sample({ + clock: $txWrappers.updates, + fn: (txWrappers) => { + const signatories = txWrappers.reduce((acc, wrapper) => { + if (wrapper.kind === WrapperKind.MULTISIG) acc.push(wrapper.signatories); + + return acc; + }, []); + + const proxyWrapper = txWrappers.find(({ kind }) => kind === WrapperKind.PROXY) as ProxyTxWrapper; + + return { + signatories, + proxyAccount: proxyWrapper?.proxyAccount || null, + isProxy: transactionService.hasProxy(txWrappers), + isMultisig: transactionService.hasMultisig(txWrappers), + }; + }, + target: formModel.events.txWrapperChanged, +}); + +sample({ + clock: [$maxValidators.updates, formModel.output.formChanged, validatorsModel.output.formSubmitted], + source: $nominateData, + filter: (nominateData, data) => Boolean(nominateData) || typeof data !== 'number', + fn: (nominateData, data) => { + if (typeof data === 'number') { + return { ...(nominateData || ({} as NominateData)), validators: Array(data).fill({ address: TEST_ADDRESS }) }; + } + + if (Array.isArray(data)) { + return { ...nominateData!, validators: data! }; + } + + return { ...data!, validators: nominateData?.validators || [] }; + }, + target: $nominateData, +}); + +sample({ + clock: $nominateData.updates, + source: $walletData, + filter: (walletData, nominateData) => Boolean(walletData) && Boolean(nominateData), + fn: (walletData, nominateData) => { + return nominateData!.shards.map((shard) => { + return transactionBuilder.buildNominate({ + chain: walletData!.chain, + accountId: shard.accountId, + nominators: nominateData!.validators.map(({ address }) => address), + }); + }); + }, + target: $pureTxs, +}); + +sample({ + clock: $transactions, + source: $api, + filter: (api, transactions) => Boolean(api) && Boolean(transactions?.length), + fn: (api, transactions) => ({ + api: api!, + transaction: transactions![0].wrappedTx, + }), + target: getTransactionFeeFx, +}); + +sample({ + clock: $txWrappers, + source: $api, + filter: (api, txWrappers) => Boolean(api) && transactionService.hasMultisig(txWrappers), + fn: (api, txWrappers) => { + const wrapper = txWrappers.find(({ kind }) => kind === WrapperKind.MULTISIG) as MultisigTxWrapper; + + return { + api: api!, + threshold: wrapper?.multisigAccount.threshold || 0, + }; + }, + target: getMultisigDepositFx, +}); + +sample({ + clock: getTransactionFeeFx.pending, + target: [formModel.events.isFeeLoadingChanged, confirmModel.events.isFeeLoadingChanged], +}); + +sample({ + clock: getTransactionFeeFx.doneData, + source: { + transactions: $transactions, + feeData: $feeData, + }, + fn: ({ transactions, feeData }, fee) => { + const totalFee = new BN(fee).muln(transactions!.length).toString(); + + return { ...feeData, fee, totalFee }; + }, + target: $feeData, +}); + +sample({ + clock: getMultisigDepositFx.doneData, + source: $feeData, + fn: (feeData, multisigDeposit) => ({ ...feeData, multisigDeposit }), + target: $feeData, +}); + +sample({ + clock: $feeData.updates, + target: [formModel.events.feeDataChanged, confirmModel.events.feeDataChanged], +}); + +// Steps + +sample({ clock: stepChanged, target: $step }); + +sample({ + clock: flowStarted, + target: formModel.events.formInitiated, +}); + +sample({ + clock: flowStarted, + fn: () => Step.INIT, + target: stepChanged, +}); + +sample({ + clock: formModel.output.formSubmitted, + source: $walletData, + filter: (walletData: WalletData | null): walletData is WalletData => Boolean(walletData), + fn: ({ chain }) => ({ + event: { chain, asset: getRelaychainAsset(chain.assets)! }, + step: Step.VALIDATORS, + }), + target: spread({ + event: validatorsModel.events.formInitiated, + step: stepChanged, + }), +}); + +sample({ + clock: validatorsModel.output.formSubmitted, + source: { + nominateData: $nominateData, + feeData: $feeData, + walletData: $walletData, + txWrappers: $txWrappers, + }, + filter: ({ nominateData, walletData }) => Boolean(nominateData) && Boolean(walletData), + fn: ({ nominateData, feeData, walletData, txWrappers }) => { + const wrapper = txWrappers.find(({ kind }) => kind === WrapperKind.PROXY) as ProxyTxWrapper; + + return { + event: { + chain: walletData!.chain, + asset: getRelaychainAsset(walletData!.chain.assets)!, + ...nominateData!, + ...feeData, + ...(wrapper && { proxiedAccount: wrapper.proxiedAccount }), + ...(wrapper && { shards: [wrapper.proxyAccount] }), + }, + step: Step.CONFIRM, + }; + }, + target: spread({ + event: confirmModel.events.formInitiated, + step: stepChanged, + }), +}); + +sample({ + clock: confirmModel.output.formSubmitted, + source: { + nominateData: $nominateData, + walletData: $walletData, + transactions: $transactions, + txWrappers: $txWrappers, + }, + filter: ({ nominateData, walletData, transactions }) => { + return Boolean(nominateData) && Boolean(walletData) && Boolean(transactions); + }, + fn: ({ nominateData, walletData, transactions, txWrappers }) => { + const wrapper = txWrappers.find(({ kind }) => kind === WrapperKind.PROXY) as ProxyTxWrapper; + + return { + event: { + chain: walletData!.chain, + accounts: wrapper ? [wrapper.proxyAccount] : nominateData!.shards, + signatory: nominateData!.signatory, + transactions: transactions!.map((tx) => tx.wrappedTx), + }, + step: Step.SIGN, + }; + }, + target: spread({ + event: signModel.events.formInitiated, + step: stepChanged, + }), +}); + +sample({ + clock: signModel.output.formSubmitted, + source: { + nominateData: $nominateData, + walletData: $walletData, + transactions: $transactions, + }, + filter: ({ nominateData, walletData, transactions }) => { + return Boolean(nominateData) && Boolean(walletData) && Boolean(transactions); + }, + fn: (nominateFlowData, signParams) => ({ + event: { + ...signParams, + chain: nominateFlowData.walletData!.chain, + account: nominateFlowData.nominateData!.shards[0], + signatory: nominateFlowData.nominateData!.signatory, + description: nominateFlowData.nominateData!.description, + transactions: nominateFlowData.transactions!.map((tx) => tx.coreTx), + multisigTxs: nominateFlowData.transactions!.map((tx) => tx.multisigTx).filter(nonNullable), + }, + step: Step.SUBMIT, + }), + target: spread({ + event: submitModel.events.formInitiated, + step: stepChanged, + }), +}); + +sample({ + clock: delay(submitModel.output.formSubmitted, 2000), + target: flowFinished, +}); + +sample({ + clock: flowFinished, + fn: () => Step.NONE, + target: [stepChanged, formModel.events.formCleared, validatorsModel.events.formCleared], +}); + +export const nominateModel = { + $step, + $walletData, + events: { + flowStarted, + stepChanged, + }, + output: { + flowFinished, + }, +}; diff --git a/src/renderer/widgets/Staking/Nominate/model/validators-model.ts b/src/renderer/widgets/Staking/Nominate/model/validators-model.ts new file mode 100644 index 0000000000..19a96b26bd --- /dev/null +++ b/src/renderer/widgets/Staking/Nominate/model/validators-model.ts @@ -0,0 +1,186 @@ +import { createEvent, createEffect, combine, sample, restore, createStore } from 'effector'; +import { ApiPromise } from '@polkadot/api'; +import { pending } from 'patronum'; + +import { Chain, Asset, Validator, EraIndex } from '@shared/core'; +import { networkModel, networkUtils } from '@entities/network'; +import { validatorsService, ValidatorMap } from '@entities/staking'; +import { eraService } from '@entities/staking/api'; +import { isStringsMatchQuery } from '@shared/lib/utils'; + +type Input = { + chain: Chain; + asset: Asset; +}; + +const formInitiated = createEvent(); +const formSubmitted = createEvent(); +const formCleared = createEvent(); +const queryChanged = createEvent(); +const validatorToggled = createEvent(); +const validatorsSubmitted = createEvent(); + +const $query = restore(queryChanged, '').reset(formCleared); +const $validatorsStore = restore(formInitiated, null).reset(formCleared); + +const $era = createStore(null).reset(formCleared); +const $maxValidators = createStore(0).reset(formCleared); +const $validators = createStore([]).reset(formCleared); +const $selectedValidators = createStore({}).reset(formCleared); + +const getActiveEraFx = createEffect((api: ApiPromise): Promise => { + return eraService.getActiveEra(api); +}); + +const getMaxValidatorsFx = createEffect((api: ApiPromise): number => { + return validatorsService.getMaxValidators(api); +}); + +type ValidatorsParams = { + api: ApiPromise; + era: EraIndex; + isLightClient: boolean; +}; +const getValidatorsFx = createEffect(({ api, era, isLightClient }: ValidatorsParams): Promise => { + return validatorsService.getValidatorsWithInfo(api, era, isLightClient); +}); + +const $api = combine( + { + apis: networkModel.$apis, + store: $validatorsStore, + }, + ({ apis, store }) => { + return store ? apis[store.chain.chainId] : null; + }, +); + +const $filteredValidators = combine( + { + query: $query, + validators: $validators, + }, + ({ query, validators }) => { + if (!query) return validators; + + return validators.filter((validator) => { + return isStringsMatchQuery(query, [ + validator.address, + validator.identity?.subName || '', + validator.identity?.parent.name || '', + ]); + }); + }, +); + +const $selectedAmount = combine($selectedValidators, (selectedValidators) => { + return Object.keys(selectedValidators).length; +}); + +const $canSubmit = combine( + { + selectedAmount: $selectedAmount, + maxValidators: $maxValidators, + }, + ({ selectedAmount, maxValidators }) => { + return selectedAmount > 0 && selectedAmount <= maxValidators; + }, +); + +sample({ + clock: $api.updates, + source: $maxValidators, + filter: (maxValidators, api) => !maxValidators && Boolean(api), + fn: (_, api) => api!, + target: getMaxValidatorsFx, +}); + +sample({ + clock: getMaxValidatorsFx.doneData, + target: $maxValidators, +}); + +sample({ + clock: $api.updates, + source: $era, + filter: (era, api) => !era && Boolean(api), + fn: (_, api) => api!, + target: getActiveEraFx, +}); + +sample({ + clock: getActiveEraFx.doneData, + filter: (era): era is EraIndex => Boolean(era), + target: $era, +}); + +sample({ + clock: $era.updates, + source: { + api: $api, + connections: networkModel.$connections, + validatorsStore: $validatorsStore, + validators: $validators, + }, + filter: ({ validatorsStore, validators }, era) => { + return Boolean(validatorsStore) && Boolean(era) && validators.length === 0; + }, + fn: ({ api, connections, validatorsStore }, era) => { + const isLightClient = networkUtils.isLightClientConnection(connections[validatorsStore!.chain.chainId]); + + return { api: api!, era: era!, isLightClient }; + }, + target: getValidatorsFx, +}); + +sample({ + clock: getValidatorsFx.doneData, + fn: (validatorsMap) => Object.values(validatorsMap), + target: $validators, +}); + +sample({ + clock: validatorToggled, + source: $selectedValidators, + filter: (_, validator) => !validator.blocked, + fn: (selectedValidators, validator) => { + const { [validator.address]: validatorToRemove, ...rest } = selectedValidators; + + return validatorToRemove ? rest : { ...rest, [validator.address]: validator }; + }, + target: $selectedValidators, +}); + +sample({ + clock: validatorsSubmitted, + source: { + selectedValidators: $selectedValidators, + selectedAmount: $selectedAmount, + }, + filter: ({ selectedAmount }) => Boolean(selectedAmount), + fn: ({ selectedValidators }) => Object.values(selectedValidators), + target: formSubmitted, +}); + +export const validatorsModel = { + $query, + $validatorsStore, + $validators: $filteredValidators, + $maxValidators, + $selectedValidators, + $selectedAmount, + + $api, + $canSubmit, + $isValidatorsLoading: pending([getActiveEraFx, getValidatorsFx]), + events: { + formInitiated, + formCleared, + queryChanged, + validatorToggled, + validatorsSubmitted, + }, + output: { + formSubmitted, + }, +}; diff --git a/src/renderer/widgets/Unstake/ui/Confirmation.tsx b/src/renderer/widgets/Staking/Nominate/ui/Confirmation.tsx similarity index 73% rename from src/renderer/widgets/Unstake/ui/Confirmation.tsx rename to src/renderer/widgets/Staking/Nominate/ui/Confirmation.tsx index 872d6738f4..70b7d3c6c9 100644 --- a/src/renderer/widgets/Unstake/ui/Confirmation.tsx +++ b/src/renderer/widgets/Staking/Nominate/ui/Confirmation.tsx @@ -4,12 +4,13 @@ import { Button, DetailRow, FootnoteText, Icon, Tooltip, CaptionText } from '@sh import { useI18n } from '@app/providers'; import { SignButton } from '@entities/operation/ui/SignButton'; import { AddressWithExplorers, WalletIcon, ExplorersPopover, WalletCardSm, accountUtils } from '@entities/wallet'; -import { cnTw } from '@shared/lib/utils'; import { AssetBalance } from '@entities/asset'; import { AssetFiatBalance } from '@entities/price/ui/AssetFiatBalance'; import { confirmModel } from '../model/confirm-model'; -import { AccountsModal, StakingPopover, UnstakingDuration } from '@entities/staking'; +import { StakingPopover, SelectedValidatorsModal, AccountsModal } from '@entities/staking'; import { useToggle } from '@shared/lib/hooks'; +import { FeeLoader } from '@entities/transaction'; +import { priceProviderModel } from '@entities/price'; type Props = { onGoBack: () => void; @@ -18,31 +19,26 @@ type Props = { export const Confirmation = ({ onGoBack }: Props) => { const { t } = useI18n(); - const api = useUnit(confirmModel.$api); - const confirmStore = useUnit(confirmModel.$confirmStore); const initiatorWallet = useUnit(confirmModel.$initiatorWallet); const signerWallet = useUnit(confirmModel.$signerWallet); const proxiedWallet = useUnit(confirmModel.$proxiedWallet); + const feeData = useUnit(confirmModel.$feeData); + const isFeeLoading = useUnit(confirmModel.$isFeeLoading); + + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); + const [isAccountsOpen, toggleAccounts] = useToggle(); + const [isValidatorsOpen, toggleValidators] = useToggle(); - if (!confirmStore || !api || !initiatorWallet) return null; + if (!confirmStore || !initiatorWallet) return null; return ( <> -
+
- - -
- - -
+ {confirmStore.description} @@ -99,10 +95,7 @@ export const Confirmation = ({ onGoBack }: Props) => { {confirmStore.shards.length > 1 ? ( + +
{accountUtils.isMultisigAccount(confirmStore.shards[0]) && ( @@ -151,46 +156,48 @@ export const Confirmation = ({ onGoBack }: Props) => { } >
- - + +
)} {t('staking.networkFee', { count: confirmStore.shards.length || 1 })}
} - className="text-text-primary" > -
- - -
+ {isFeeLoading ? ( + + ) : ( +
+ + +
+ )} {confirmStore.shards.length > 1 && ( {t('staking.networkFeeTotal')}} className="text-text-primary" + label={{t('staking.networkFeeTotal')}} > -
- - -
+ {isFeeLoading ? ( + + ) : ( +
+ + +
+ )}
)} - - {t('staking.confirmation.hintUnstakePeriod')} {' ('} - - {')'} - - {t('staking.confirmation.hintNoRewards')} - {t('staking.confirmation.hintWithdraw')} + {t('staking.confirmation.hintNewValidators')} @@ -199,20 +206,28 @@ export const Confirmation = ({ onGoBack }: Props) => { {t('operation.goBackButton')} - +
+ + ); }; diff --git a/src/renderer/widgets/Staking/Nominate/ui/Nominate.tsx b/src/renderer/widgets/Staking/Nominate/ui/Nominate.tsx new file mode 100644 index 0000000000..0ded7b9344 --- /dev/null +++ b/src/renderer/widgets/Staking/Nominate/ui/Nominate.tsx @@ -0,0 +1,48 @@ +import { useUnit } from 'effector-react'; + +import { BaseModal } from '@shared/ui'; +import { useModalClose } from '@shared/lib/hooks'; +import { OperationTitle } from '@entities/chain'; +import { useI18n } from '@app/providers'; +import { OperationSign, OperationSubmit } from '@features/operations'; +import { NominateForm } from './NominateForm'; +import { Validators } from './Validators'; +import { Confirmation } from './Confirmation'; +import { nominateUtils } from '../lib/nominate-utils'; +import { nominateModel } from '../model/nominate-model'; +import { Step } from '../lib/types'; + +export const Nominate = () => { + const { t } = useI18n(); + + const step = useUnit(nominateModel.$step); + const walletData = useUnit(nominateModel.$walletData); + + const [isModalOpen, closeModal] = useModalClose(!nominateUtils.isNoneStep(step), nominateModel.output.flowFinished); + + if (!walletData) return null; + + if (nominateUtils.isSubmitStep(step)) return ; + + return ( + } + onClose={closeModal} + > + {nominateUtils.isInitStep(step) && } + {nominateUtils.isValidatorsStep(step) && ( + nominateModel.events.stepChanged(Step.INIT)} /> + )} + {nominateUtils.isConfirmStep(step) && ( + nominateModel.events.stepChanged(Step.VALIDATORS)} /> + )} + {nominateUtils.isSignStep(step) && ( + nominateModel.events.stepChanged(Step.CONFIRM)} /> + )} + + ); +}; diff --git a/src/renderer/widgets/Unstake/ui/UnstakeForm.tsx b/src/renderer/widgets/Staking/Nominate/ui/NominateForm.tsx similarity index 66% rename from src/renderer/widgets/Unstake/ui/UnstakeForm.tsx rename to src/renderer/widgets/Staking/Nominate/ui/NominateForm.tsx index 67a1705a43..091e62d155 100644 --- a/src/renderer/widgets/Unstake/ui/UnstakeForm.tsx +++ b/src/renderer/widgets/Staking/Nominate/ui/NominateForm.tsx @@ -3,21 +3,22 @@ import { FormEvent } from 'react'; import { useUnit } from 'effector-react'; import { useI18n } from '@app/providers'; -import { MultisigAccount } from '@shared/core'; import { accountUtils, AccountAddress, ProxyWalletAlert } from '@entities/wallet'; import { toAddress, toShortAddress, formatBalance } from '@shared/lib/utils'; import { AssetBalance } from '@entities/asset'; -import { MultisigDepositWithLabel, FeeWithLabel } from '@entities/transaction'; -import { formModel } from '../model/form-model'; -import { Select, Input, Button, InputHint, AmountInput, MultiSelect, Shimmering } from '@shared/ui'; import { DropdownOption } from '@shared/ui/types'; +import { formModel } from '../model/form-model'; +import { AssetFiatBalance } from '@entities/price/ui/AssetFiatBalance'; +import { FeeLoader } from '@entities/transaction'; +import { priceProviderModel } from '@entities/price'; +import { Select, Input, Button, InputHint, MultiSelect, Icon, DetailRow, FootnoteText, Tooltip } from '@shared/ui'; type Props = { onGoBack: () => void; }; -export const UnstakeForm = ({ onGoBack }: Props) => { - const { submit } = useForm(formModel.$unstakeForm); +export const NominateForm = ({ onGoBack }: Props) => { + const { submit } = useForm(formModel.$bondForm); const submitForm = (event: FormEvent) => { event.preventDefault(); @@ -25,12 +26,11 @@ export const UnstakeForm = ({ onGoBack }: Props) => { }; return ( -
+
-
@@ -44,16 +44,16 @@ export const UnstakeForm = ({ onGoBack }: Props) => { const ProxyFeeAlert = () => { const { fields: { shards }, - } = useForm(formModel.$unstakeForm); + } = useForm(formModel.$bondForm); - const fee = useUnit(formModel.$fee); + const feeData = useUnit(formModel.$feeData); const balance = useUnit(formModel.$proxyBalance); const network = useUnit(formModel.$networkStore); const proxyWallet = useUnit(formModel.$proxyWallet); if (!network || !proxyWallet || !shards.hasError()) return null; - const formattedFee = formatBalance(fee, network.asset.precision).value; + const formattedFee = formatBalance(feeData.fee, network.asset.precision).value; const formattedBalance = formatBalance(balance, network.asset.precision).value; return ( @@ -72,14 +72,14 @@ const AccountsSelector = () => { const { fields: { shards }, - } = useForm(formModel.$unstakeForm); + } = useForm(formModel.$bondForm); const accounts = useUnit(formModel.$accounts); const network = useUnit(formModel.$networkStore); if (!network || accounts.length <= 1) return null; - const options = accounts.map(({ account, balances }) => { + const options = accounts.map(({ account, balance }) => { const isShard = accountUtils.isShardAccount(account); const address = toAddress(account.accountId, { prefix: network.chain.addressPrefix }); @@ -95,7 +95,7 @@ const AccountsSelector = () => { name={isShard ? toShortAddress(address, 16) : account.name} canCopy={false} /> - +
), }; @@ -124,7 +124,7 @@ const SignatorySelector = () => { const { fields: { signatory }, - } = useForm(formModel.$unstakeForm); + } = useForm(formModel.$bondForm); const signatories = useUnit(formModel.$signatories); const isMultisig = useUnit(formModel.$isMultisig); @@ -175,43 +175,12 @@ const SignatorySelector = () => { ); }; -const Amount = () => { - const { t } = useI18n(); - - const { - fields: { amount }, - } = useForm(formModel.$unstakeForm); - - const unstakeBalanceRange = useUnit(formModel.$unstakeBalanceRange); - const isStakingLoading = useUnit(formModel.$isStakingLoading); - const network = useUnit(formModel.$networkStore); - - if (!network) return null; - - return ( -
- : unstakeBalanceRange} - balancePlaceholder={t('general.input.availableLabel')} - placeholder={t('general.input.amountLabel')} - asset={network.asset} - onChange={amount.onChange} - /> - - {t(amount.errorText())} - -
- ); -}; - const Description = () => { const { t } = useI18n(); const { fields: { description }, - } = useForm(formModel.$unstakeForm); + } = useForm(formModel.$bondForm); const isMultisig = useUnit(formModel.$isMultisig); @@ -240,45 +209,71 @@ const FeeSection = () => { const { fields: { shards }, - } = useForm(formModel.$unstakeForm); + } = useForm(formModel.$bondForm); - const api = useUnit(formModel.$api); const network = useUnit(formModel.$networkStore); - const transactions = useUnit(formModel.$transactions); + const feeData = useUnit(formModel.$feeData); + const isFeeLoading = useUnit(formModel.$isFeeLoading); const isMultisig = useUnit(formModel.$isMultisig); + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); + if (!network || shards.value.length === 0) return null; return (
{isMultisig && ( - + + + {t('staking.multisigDepositLabel')} + + + + + } + > +
+ + +
+
)} - - - {transactions && transactions.length > 1 && ( - + + {t('staking.networkFee', { count: shards.value.length || 1 })} + + } + className="text-text-primary" + > + {isFeeLoading ? ( + + ) : ( +
+ + +
+ )} +
+ + {shards.value.length > 1 && ( + {t('staking.networkFeeTotal')}} + className="text-text-primary" + > + {isFeeLoading ? ( + + ) : ( +
+ + +
+ )} +
)}
); diff --git a/src/renderer/widgets/Staking/Nominate/ui/Validators.tsx b/src/renderer/widgets/Staking/Nominate/ui/Validators.tsx new file mode 100644 index 0000000000..863d3b47f9 --- /dev/null +++ b/src/renderer/widgets/Staking/Nominate/ui/Validators.tsx @@ -0,0 +1,95 @@ +import { useUnit } from 'effector-react'; + +import { Button, SmallTitleText, Shimmering, SearchInput, Loader, Icon, BodyText, Checkbox } from '@shared/ui'; +import { useI18n } from '@app/providers'; +import { ValidatorsTable } from '@entities/staking'; +import { cnTw } from '@shared/lib/utils'; +import { validatorsModel } from '../model/validators-model'; + +type Props = { + onGoBack: () => void; +}; + +export const Validators = ({ onGoBack }: Props) => { + const { t } = useI18n(); + + const query = useUnit(validatorsModel.$query); + const validatorsStore = useUnit(validatorsModel.$validatorsStore); + const validators = useUnit(validatorsModel.$validators); + const maxValidators = useUnit(validatorsModel.$maxValidators); + const selectedValidators = useUnit(validatorsModel.$selectedValidators); + const selectedAmount = useUnit(validatorsModel.$selectedAmount); + const isValidatorsLoading = useUnit(validatorsModel.$isValidatorsLoading); + const canSubmit = useUnit(validatorsModel.$canSubmit); + + if (!validatorsStore) return null; + + return ( +
+
+ {t('staking.validators.selectedValidatorsLabel')} + {isValidatorsLoading ? ( + + ) : ( + + {t('staking.validators.maxValidatorsLabel', { max: maxValidators })} + + )} + +
+ + {isValidatorsLoading && ( +
+ +
+ )} + + {!isValidatorsLoading && validators.length === 0 && ( +
+ + + {t('staking.validators.noValidatorsLabel')} + +
+ )} + + {!isValidatorsLoading && validators.length > 0 && ( + + {(validator, rowStyle) => ( +
  • + validatorsModel.events.validatorToggled(validator)} + > +
    + +
    +
    +
  • + )} +
    + )} + +
    + + +
    +
    + ); +}; diff --git a/src/renderer/widgets/Staking/Unstake/model/form-model.ts b/src/renderer/widgets/Staking/Unstake/model/form-model.ts index bea1907b86..4e113a3152 100644 --- a/src/renderer/widgets/Staking/Unstake/model/form-model.ts +++ b/src/renderer/widgets/Staking/Unstake/model/form-model.ts @@ -10,7 +10,14 @@ import { balanceModel, balanceUtils } from '@entities/balance'; import { networkModel, networkUtils } from '@entities/network'; import type { Account, PartialBy, ProxiedAccount, Chain, Asset, Address, ChainId } from '@shared/core'; import { useStakingData, StakingMap } from '@entities/staking'; -import { transferableAmount, getRelaychainAsset, toAddress, dictionary, formatAmount } from '@shared/lib/utils'; +import { + transferableAmount, + getRelaychainAsset, + toAddress, + dictionary, + formatAmount, + ZERO_BALANCE, +} from '@shared/lib/utils'; import { NetworkStore } from '../lib/types'; import { Transaction, @@ -18,6 +25,7 @@ import { transactionService, MultisigTxWrapper, ProxyTxWrapper, + DESCRIPTION_LENGTH, } from '@entities/transaction'; type BalanceMap = { balance: string; stake: string }; @@ -55,7 +63,7 @@ const isFeeLoadingChanged = createEvent(); const $networkStore = createStore<{ chain: Chain; asset: Asset } | null>(null); const $staking = restore(stakingSet, null); -const $minBond = createStore('0'); +const $minBond = createStore(ZERO_BALANCE); const $stakingUnsub = createStore<() => void>(noop); const $shards = createStore([]); @@ -63,13 +71,13 @@ const $isMultisig = createStore(false); const $isProxy = createStore(false); const $accountsBalances = createStore([]); -const $unstakeBalanceRange = createStore('0'); -const $signatoryBalance = createStore('0'); -const $proxyBalance = createStore('0'); +const $unstakeBalanceRange = createStore(ZERO_BALANCE); +const $signatoryBalance = createStore(ZERO_BALANCE); +const $proxyBalance = createStore(ZERO_BALANCE); -const $fee = restore(feeChanged, '0'); -const $totalFee = restore(totalFeeChanged, '0'); -const $multisigDeposit = restore(multisigDepositChanged, '0'); +const $fee = restore(feeChanged, ZERO_BALANCE); +const $totalFee = restore(totalFeeChanged, ZERO_BALANCE); +const $multisigDeposit = restore(multisigDepositChanged, ZERO_BALANCE); const $isFeeLoading = restore(isFeeLoadingChanged, true); const $selectedSignatories = createStore([]); @@ -151,7 +159,7 @@ const $unstakeForm = createForm({ { name: 'notZero', errorText: 'transfer.requiredAmountError', - validator: (value) => value !== '0', + validator: (value) => value !== ZERO_BALANCE, }, { name: 'notEnoughBalance', @@ -190,7 +198,7 @@ const $unstakeForm = createForm({ { name: 'maxLength', errorText: 'transfer.descriptionLengthError', - validator: (value) => !value || value.length <= 120, + validator: (value) => !value || value.length <= DESCRIPTION_LENGTH, }, ], }, @@ -297,7 +305,7 @@ const $accounts = combine( return shards.map((shard) => { const balance = balanceUtils.getBalance(balances, shard.accountId, chain.chainId, asset.assetId.toString()); const address = toAddress(shard.accountId, { prefix: chain.addressPrefix }); - const activeStake = staking[address]?.active || '0'; + const activeStake = staking[address]?.active || ZERO_BALANCE; return { account: shard, @@ -374,15 +382,15 @@ const $pureTxs = combine( return form.shards.map((shard) => { const address = toAddress(shard.accountId, { prefix: network.chain.addressPrefix }); - const leftAmount = new BN(staking?.[address]?.active || '0').sub(new BN(amount)); - const chill = leftAmount.lte(new BN(minBond)); + const leftAmount = new BN(staking?.[address]?.active || ZERO_BALANCE).sub(new BN(amount)); + const withChill = leftAmount.lte(new BN(minBond)); return transactionBuilder.buildUnstake({ chain: network.chain, asset: network.asset, accountId: shard.accountId, - amount: form.amount || '0', - chill, + amount: form.amount || ZERO_BALANCE, + withChill, }); }); }, @@ -489,12 +497,12 @@ sample({ }, filter: ({ staking, networkStore }) => Boolean(staking) && Boolean(networkStore), fn: ({ staking, networkStore, shards }) => { - if (shards.length === 0) return '0'; + if (shards.length === 0) return ZERO_BALANCE; const stakedBalances = shards.map((shard) => { const address = toAddress(shard.accountId, { prefix: networkStore!.chain.addressPrefix }); - return staking![address]?.active || '0'; + return staking![address]?.active || ZERO_BALANCE; }); const minStakedBalance = stakedBalances.reduce((acc, balance) => { @@ -503,7 +511,7 @@ sample({ return new BN(balance).lt(new BN(acc)) ? balance : acc; }, stakedBalances[0]); - return ['0', minStakedBalance]; + return [ZERO_BALANCE, minStakedBalance]; }, target: $unstakeBalanceRange, }); @@ -540,7 +548,7 @@ sample({ fn: (signatories, signatory) => { const match = signatories[0].find(({ signer }) => signer.id === signatory.id); - return match?.balance || '0'; + return match?.balance || ZERO_BALANCE; }, target: $signatoryBalance, }); diff --git a/src/renderer/widgets/Withdraw/index.ts b/src/renderer/widgets/Staking/Withdraw/index.ts similarity index 100% rename from src/renderer/widgets/Withdraw/index.ts rename to src/renderer/widgets/Staking/Withdraw/index.ts diff --git a/src/renderer/widgets/Withdraw/lib/types.ts b/src/renderer/widgets/Staking/Withdraw/lib/types.ts similarity index 100% rename from src/renderer/widgets/Withdraw/lib/types.ts rename to src/renderer/widgets/Staking/Withdraw/lib/types.ts diff --git a/src/renderer/widgets/Withdraw/lib/withdraw-utils.ts b/src/renderer/widgets/Staking/Withdraw/lib/withdraw-utils.ts similarity index 100% rename from src/renderer/widgets/Withdraw/lib/withdraw-utils.ts rename to src/renderer/widgets/Staking/Withdraw/lib/withdraw-utils.ts diff --git a/src/renderer/widgets/Unstake/model/confirm-model.ts b/src/renderer/widgets/Staking/Withdraw/model/confirm-model.ts similarity index 100% rename from src/renderer/widgets/Unstake/model/confirm-model.ts rename to src/renderer/widgets/Staking/Withdraw/model/confirm-model.ts diff --git a/src/renderer/widgets/Withdraw/model/form-model.ts b/src/renderer/widgets/Staking/Withdraw/model/form-model.ts similarity index 98% rename from src/renderer/widgets/Withdraw/model/form-model.ts rename to src/renderer/widgets/Staking/Withdraw/model/form-model.ts index f9644f25b2..af0f825f34 100644 --- a/src/renderer/widgets/Withdraw/model/form-model.ts +++ b/src/renderer/widgets/Staking/Withdraw/model/form-model.ts @@ -9,7 +9,8 @@ import { walletModel, walletUtils, accountUtils } from '@entities/wallet'; import { balanceModel, balanceUtils } from '@entities/balance'; import { networkModel, networkUtils } from '@entities/network'; import type { Account, PartialBy, ProxiedAccount, Chain, Asset, Address, ChainId } from '@shared/core'; -import { useStakingData, StakingMap, useEra } from '@entities/staking'; +import { useStakingData, StakingMap, eraService } from '@entities/staking'; +import { NetworkStore } from '../lib/types'; import { transferableAmount, getRelaychainAsset, @@ -19,7 +20,6 @@ import { ZERO_BALANCE, redeemableAmount, } from '@shared/lib/utils'; -import { NetworkStore } from '../lib/types'; import { Transaction, transactionBuilder, @@ -66,7 +66,6 @@ const isFeeLoadingChanged = createEvent(); const $networkStore = createStore<{ chain: Chain; asset: Asset } | null>(null); const $staking = restore(stakingSet, null); const $era = restore(eraSet, null); -const $minBond = createStore(ZERO_BALANCE); const $stakingUnsub = createStore<() => void>(noop); const $eraUnsub = createStore<() => void>(noop); @@ -185,7 +184,7 @@ const subscribeStakingFx = createEffect(({ chainId, api, addresses }: StakingPar const subscribeEraFx = createEffect((api: ApiPromise): Promise<() => void> => { const boundEraSet = scopeBind(eraSet, { safe: true }); - return useEra().subscribeActiveEra(api, (era) => { + return eraService.subscribeActiveEra(api, (era) => { if (!era) return; boundEraSet(era); diff --git a/src/renderer/widgets/Withdraw/model/withdraw-model.ts b/src/renderer/widgets/Staking/Withdraw/model/withdraw-model.ts similarity index 98% rename from src/renderer/widgets/Withdraw/model/withdraw-model.ts rename to src/renderer/widgets/Staking/Withdraw/model/withdraw-model.ts index 75bd92dfab..de9da964c4 100644 --- a/src/renderer/widgets/Withdraw/model/withdraw-model.ts +++ b/src/renderer/widgets/Staking/Withdraw/model/withdraw-model.ts @@ -116,7 +116,7 @@ sample({ signatory: withdrawData.withdrawStore!.signatory, description: withdrawData.withdrawStore!.description, transactions: withdrawData.coreTxs!, - multisigTxs: withdrawData.multisigTxs || undefined, + multisigTxs: withdrawData.multisigTxs || [], }, step: Step.SUBMIT, }), diff --git a/src/renderer/widgets/Withdraw/ui/Confirmation.tsx b/src/renderer/widgets/Staking/Withdraw/ui/Confirmation.tsx similarity index 99% rename from src/renderer/widgets/Withdraw/ui/Confirmation.tsx rename to src/renderer/widgets/Staking/Withdraw/ui/Confirmation.tsx index 172539af47..2cd2e80cfa 100644 --- a/src/renderer/widgets/Withdraw/ui/Confirmation.tsx +++ b/src/renderer/widgets/Staking/Withdraw/ui/Confirmation.tsx @@ -209,7 +209,6 @@ export const Confirmation = ({ onGoBack }: Props) => { amounts={['0']} chainId={confirmStore.chain.chainId} asset={confirmStore.asset} - explorers={confirmStore.chain.explorers} addressPrefix={confirmStore.chain.addressPrefix} onClose={toggleAccounts} /> diff --git a/src/renderer/widgets/Withdraw/ui/Withdraw.tsx b/src/renderer/widgets/Staking/Withdraw/ui/Withdraw.tsx similarity index 100% rename from src/renderer/widgets/Withdraw/ui/Withdraw.tsx rename to src/renderer/widgets/Staking/Withdraw/ui/Withdraw.tsx diff --git a/src/renderer/widgets/Withdraw/ui/WithdrawForm.tsx b/src/renderer/widgets/Staking/Withdraw/ui/WithdrawForm.tsx similarity index 100% rename from src/renderer/widgets/Withdraw/ui/WithdrawForm.tsx rename to src/renderer/widgets/Staking/Withdraw/ui/WithdrawForm.tsx diff --git a/src/renderer/widgets/Unstake/index.ts b/src/renderer/widgets/Unstake/index.ts deleted file mode 100644 index 74e3f1d127..0000000000 --- a/src/renderer/widgets/Unstake/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Unstake } from './ui/Unstake'; -export { unstakeModel } from './model/unstake-model'; diff --git a/src/renderer/widgets/Unstake/lib/unstake-utils.ts b/src/renderer/widgets/Unstake/lib/unstake-utils.ts deleted file mode 100644 index f7f24c3124..0000000000 --- a/src/renderer/widgets/Unstake/lib/unstake-utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Step } from './types'; - -export const unstakeUtils = { - isNoneStep, - isInitStep, - isConfirmStep, - isSignStep, - isSubmitStep, -}; - -function isNoneStep(step: Step): boolean { - return step === Step.NONE; -} - -function isInitStep(step: Step): boolean { - return step === Step.INIT; -} - -function isConfirmStep(step: Step): boolean { - return step === Step.CONFIRM; -} - -function isSignStep(step: Step): boolean { - return step === Step.SIGN; -} - -function isSubmitStep(step: Step): boolean { - return step === Step.SUBMIT; -} diff --git a/src/renderer/widgets/Unstake/model/form-model.ts b/src/renderer/widgets/Unstake/model/form-model.ts deleted file mode 100644 index 4e113a3152..0000000000 --- a/src/renderer/widgets/Unstake/model/form-model.ts +++ /dev/null @@ -1,698 +0,0 @@ -import { createEffect, attach, createEvent, createStore, combine, sample, restore, scopeBind } from 'effector'; -import { spread } from 'patronum'; -import { createForm } from 'effector-forms'; -import { BN } from '@polkadot/util'; -import { ApiPromise } from '@polkadot/api'; -import noop from 'lodash/noop'; - -import { walletModel, walletUtils, accountUtils } from '@entities/wallet'; -import { balanceModel, balanceUtils } from '@entities/balance'; -import { networkModel, networkUtils } from '@entities/network'; -import type { Account, PartialBy, ProxiedAccount, Chain, Asset, Address, ChainId } from '@shared/core'; -import { useStakingData, StakingMap } from '@entities/staking'; -import { - transferableAmount, - getRelaychainAsset, - toAddress, - dictionary, - formatAmount, - ZERO_BALANCE, -} from '@shared/lib/utils'; -import { NetworkStore } from '../lib/types'; -import { - Transaction, - transactionBuilder, - transactionService, - MultisigTxWrapper, - ProxyTxWrapper, - DESCRIPTION_LENGTH, -} from '@entities/transaction'; - -type BalanceMap = { balance: string; stake: string }; - -type FormParams = { - shards: Account[]; - signatory: Account; - amount: string; - description: string; -}; - -type FormSubmitEvent = { - transactions: { - wrappedTx: Transaction; - multisigTx?: Transaction; - coreTx: Transaction; - }[]; - formData: PartialBy & { - proxiedAccount?: ProxiedAccount; - fee: string; - totalFee: string; - multisigDeposit: string; - }; -}; - -const formInitiated = createEvent(); -const formSubmitted = createEvent(); -const stakingSet = createEvent(); -const formCleared = createEvent(); - -const feeChanged = createEvent(); -const totalFeeChanged = createEvent(); -const multisigDepositChanged = createEvent(); -const isFeeLoadingChanged = createEvent(); - -const $networkStore = createStore<{ chain: Chain; asset: Asset } | null>(null); -const $staking = restore(stakingSet, null); -const $minBond = createStore(ZERO_BALANCE); -const $stakingUnsub = createStore<() => void>(noop); - -const $shards = createStore([]); -const $isMultisig = createStore(false); -const $isProxy = createStore(false); - -const $accountsBalances = createStore([]); -const $unstakeBalanceRange = createStore(ZERO_BALANCE); -const $signatoryBalance = createStore(ZERO_BALANCE); -const $proxyBalance = createStore(ZERO_BALANCE); - -const $fee = restore(feeChanged, ZERO_BALANCE); -const $totalFee = restore(totalFeeChanged, ZERO_BALANCE); -const $multisigDeposit = restore(multisigDepositChanged, ZERO_BALANCE); -const $isFeeLoading = restore(isFeeLoadingChanged, true); - -const $selectedSignatories = createStore([]); - -const $unstakeForm = createForm({ - fields: { - shards: { - init: [] as Account[], - rules: [ - { - name: 'noProxyFee', - source: combine({ - fee: $fee, - isProxy: $isProxy, - proxyBalance: $proxyBalance, - }), - validator: (_s, _f, { isProxy, proxyBalance, fee }) => { - if (!isProxy) return true; - - return new BN(fee).lte(new BN(proxyBalance)); - }, - }, - { - name: 'noUnstakeBalance', - errorText: 'staking.unstake.noUnstakeBalanceError', - source: combine({ - isProxy: $isProxy, - network: $networkStore, - accountsBalances: $accountsBalances, - }), - validator: (shards, form, { isProxy, network, accountsBalances }) => { - if (isProxy || shards.length === 1) return true; - - const amountBN = new BN(formatAmount(form.amount, network.asset.precision)); - - return shards.every((_, index) => amountBN.lte(new BN(accountsBalances[index].stake))); - }, - }, - ], - }, - signatory: { - init: {} as Account, - rules: [ - { - name: 'noSignatorySelected', - errorText: 'transfer.noSignatoryError', - source: $isMultisig, - validator: (signatory, _, isMultisig) => { - if (!isMultisig) return true; - - return Object.keys(signatory).length > 0; - }, - }, - { - name: 'notEnoughTokens', - errorText: 'proxy.addProxy.notEnoughMultisigTokens', - source: combine({ - fee: $fee, - isMultisig: $isMultisig, - multisigDeposit: $multisigDeposit, - signatoryBalance: $signatoryBalance, - }), - validator: (_s, _f, { fee, isMultisig, signatoryBalance, multisigDeposit }) => { - if (!isMultisig) return true; - - return new BN(multisigDeposit).add(new BN(fee)).lte(new BN(signatoryBalance)); - }, - }, - ], - }, - amount: { - init: '', - rules: [ - { - name: 'required', - errorText: 'transfer.requiredAmountError', - validator: Boolean, - }, - { - name: 'notZero', - errorText: 'transfer.requiredAmountError', - validator: (value) => value !== ZERO_BALANCE, - }, - { - name: 'notEnoughBalance', - errorText: 'transfer.notEnoughBalanceError', - source: combine({ - network: $networkStore, - unstakeBalanceRange: $unstakeBalanceRange, - }), - validator: (value, _, { network, unstakeBalanceRange }) => { - const amountBN = new BN(formatAmount(value, network.asset.precision)); - const unstakeBalance = Array.isArray(unstakeBalanceRange) ? unstakeBalanceRange[1] : unstakeBalanceRange; - - return amountBN.lte(new BN(unstakeBalance)); - }, - }, - { - name: 'insufficientBalanceForFee', - errorText: 'transfer.notEnoughBalanceForFeeError', - source: combine({ - network: $networkStore, - accountsBalances: $accountsBalances, - }), - validator: (value, form, { network, accountsBalances }) => { - const amountBN = new BN(formatAmount(value, network.asset.precision)); - - return form.shards.every((_: Account, index: number) => { - return amountBN.lte(new BN(accountsBalances[index].balance)); - }); - }, - }, - ], - }, - description: { - init: '', - rules: [ - { - name: 'maxLength', - errorText: 'transfer.descriptionLengthError', - validator: (value) => !value || value.length <= DESCRIPTION_LENGTH, - }, - ], - }, - }, - validateOn: ['submit'], -}); - -// Effects -type StakingParams = { - chainId: ChainId; - api: ApiPromise; - addresses: Address[]; -}; -const subscribeStakingFx = createEffect(({ chainId, api, addresses }: StakingParams): Promise<() => void> => { - const boundStakingSet = scopeBind(stakingSet, { safe: true }); - - return useStakingData().subscribeStaking(chainId, api, addresses, boundStakingSet); -}); - -const getMinNominatorBondFx = createEffect((api: ApiPromise): Promise => { - return useStakingData().getMinNominatorBond(api); -}); - -// Computed - -const $txWrappers = combine( - { - wallet: walletModel.$activeWallet, - wallets: walletModel.$wallets, - shards: $shards, - accounts: walletModel.$accounts, - network: $networkStore, - signatories: $selectedSignatories, - }, - ({ wallet, shards, accounts, wallets, network, signatories }) => { - if (!wallet || !network || shards.length !== 1) return []; - - const walletFiltered = wallets.filter((wallet) => { - return !walletUtils.isProxied(wallet) && !walletUtils.isWatchOnly(wallet); - }); - const walletsMap = dictionary(walletFiltered, 'id'); - const chainFilteredAccounts = accounts.filter((account) => { - if (accountUtils.isBaseAccount(account) && walletUtils.isPolkadotVault(walletsMap[account.walletId])) { - return false; - } - - return accountUtils.isChainAndCryptoMatch(account, network.chain); - }); - - return transactionService.getTxWrappers({ - wallet, - wallets: walletFiltered, - account: shards[0], - accounts: chainFilteredAccounts, - signatories, - }); - }, -); - -const $realAccounts = combine( - { - txWrappers: $txWrappers, - shards: $unstakeForm.fields.shards.$value, - }, - ({ txWrappers, shards }) => { - if (shards.length === 0) return []; - if (txWrappers.length === 0) return shards; - - if (transactionService.hasMultisig([txWrappers[0]])) { - return [(txWrappers[0] as MultisigTxWrapper).multisigAccount]; - } - - return [(txWrappers[0] as ProxyTxWrapper).proxyAccount]; - }, -); - -const $proxyWallet = combine( - { - isProxy: $isProxy, - accounts: $realAccounts, - wallets: walletModel.$wallets, - }, - ({ isProxy, accounts, wallets }) => { - if (!isProxy || accounts.length === 0) return undefined; - - return walletUtils.getWalletById(wallets, accounts[0].walletId); - }, - { skipVoid: false }, -); - -const $accounts = combine( - { - network: $networkStore, - wallet: walletModel.$activeWallet, - shards: $shards, - staking: $staking, - balances: balanceModel.$balances, - }, - ({ network, wallet, shards, staking, balances }) => { - if (!wallet || !network || !staking) return []; - - const { chain, asset } = network; - - return shards.map((shard) => { - const balance = balanceUtils.getBalance(balances, shard.accountId, chain.chainId, asset.assetId.toString()); - const address = toAddress(shard.accountId, { prefix: chain.addressPrefix }); - const activeStake = staking[address]?.active || ZERO_BALANCE; - - return { - account: shard, - balances: { balance: transferableAmount(balance), stake: activeStake }, - }; - }); - }, -); - -const $signatories = combine( - { - network: $networkStore, - txWrappers: $txWrappers, - balances: balanceModel.$balances, - }, - ({ network, txWrappers, balances }) => { - if (!network) return []; - - const { chain, asset } = network; - - return txWrappers.reduce>((acc, wrapper) => { - if (!transactionService.hasMultisig([wrapper])) return acc; - - const balancedSignatories = (wrapper as MultisigTxWrapper).signatories.map((signatory) => { - const balance = balanceUtils.getBalance(balances, signatory.accountId, chain.chainId, asset.assetId.toString()); - - return { signer: signatory, balance: transferableAmount(balance) }; - }); - - acc.push(balancedSignatories); - - return acc; - }, []); - }, -); - -const $isChainConnected = combine( - { - network: $networkStore, - statuses: networkModel.$connectionStatuses, - }, - ({ network, statuses }) => { - if (!network) return false; - - return networkUtils.isConnectedStatus(statuses[network.chain.chainId]); - }, -); - -const $api = combine( - { - apis: networkModel.$apis, - network: $networkStore, - }, - ({ apis, network }) => { - if (!network) return undefined; - - return apis[network.chain.chainId]; - }, - { skipVoid: false }, -); - -const $pureTxs = combine( - { - network: $networkStore, - form: $unstakeForm.$values, - staking: $staking, - minBond: $minBond, - isConnected: $isChainConnected, - }, - ({ network, form, staking, minBond, isConnected }) => { - if (!network || !isConnected) return undefined; - - const amount = formatAmount(form.amount, network.asset.precision); - - return form.shards.map((shard) => { - const address = toAddress(shard.accountId, { prefix: network.chain.addressPrefix }); - const leftAmount = new BN(staking?.[address]?.active || ZERO_BALANCE).sub(new BN(amount)); - const withChill = leftAmount.lte(new BN(minBond)); - - return transactionBuilder.buildUnstake({ - chain: network.chain, - asset: network.asset, - accountId: shard.accountId, - amount: form.amount || ZERO_BALANCE, - withChill, - }); - }); - }, - { skipVoid: false }, -); - -const $transactions = combine( - { - apis: networkModel.$apis, - networkStore: $networkStore, - pureTxs: $pureTxs, - txWrappers: $txWrappers, - }, - ({ apis, networkStore, pureTxs, txWrappers }) => { - if (!networkStore || !pureTxs) return undefined; - - return pureTxs.map((tx) => - transactionService.getWrappedTransaction({ - api: apis[networkStore.chain.chainId], - addressPrefix: networkStore.chain.addressPrefix, - transaction: tx, - txWrappers, - }), - ); - }, - { skipVoid: false }, -); - -const $canSubmit = combine( - { - isFormValid: $unstakeForm.$isValid, - isFeeLoading: $isFeeLoading, - isStakingLoading: subscribeStakingFx.pending, - }, - ({ isFormValid, isFeeLoading, isStakingLoading }) => { - return isFormValid && !isFeeLoading && !isStakingLoading; - }, -); - -// Fields connections - -sample({ - clock: formInitiated, - target: [$unstakeForm.reset, $selectedSignatories.reinit], -}); - -sample({ - clock: formInitiated, - filter: ({ chain, shards }) => Boolean(getRelaychainAsset(chain.assets)) && shards.length > 0, - fn: ({ chain, shards }) => ({ - shards, - networkStore: { chain, asset: getRelaychainAsset(chain.assets)! }, - }), - target: spread({ - shards: $shards, - networkStore: $networkStore, - }), -}); - -sample({ - clock: formInitiated, - source: $api, - filter: (api): api is ApiPromise => Boolean(api), - target: getMinNominatorBondFx, -}); - -sample({ - clock: getMinNominatorBondFx.doneData, - target: $minBond, -}); - -sample({ - clock: formInitiated, - source: { - networkStore: $networkStore, - api: $api, - shards: $shards, - }, - filter: ({ networkStore, api }) => { - return Boolean(networkStore) && Boolean(api); - }, - fn: ({ networkStore, api, shards }) => { - const addresses = shards.map((shard) => toAddress(shard.accountId, { prefix: networkStore!.chain.addressPrefix })); - - return { - chainId: networkStore!.chain.chainId, - api: api!, - addresses, - }; - }, - target: subscribeStakingFx, -}); - -sample({ - clock: subscribeStakingFx.doneData, - target: $stakingUnsub, -}); - -sample({ - source: { - staking: $staking, - networkStore: $networkStore, - shards: $unstakeForm.fields.shards.$value, - }, - filter: ({ staking, networkStore }) => Boolean(staking) && Boolean(networkStore), - fn: ({ staking, networkStore, shards }) => { - if (shards.length === 0) return ZERO_BALANCE; - - const stakedBalances = shards.map((shard) => { - const address = toAddress(shard.accountId, { prefix: networkStore!.chain.addressPrefix }); - - return staking![address]?.active || ZERO_BALANCE; - }); - - const minStakedBalance = stakedBalances.reduce((acc, balance) => { - if (!balance) return acc; - - return new BN(balance).lt(new BN(acc)) ? balance : acc; - }, stakedBalances[0]); - - return [ZERO_BALANCE, minStakedBalance]; - }, - target: $unstakeBalanceRange, -}); - -sample({ - clock: formInitiated, - source: $shards, - filter: (shards) => shards.length > 0, - fn: (shards) => shards, - target: $unstakeForm.fields.shards.onChange, -}); - -sample({ - source: { - accounts: $accounts, - shards: $unstakeForm.fields.shards.$value, - }, - fn: ({ accounts, shards }) => { - return accounts.reduce<{ balance: string; stake: string }[]>((acc, { account, balances }) => { - if (shards.includes(account)) { - acc.push(balances); - } - - return acc; - }, []); - }, - target: $accountsBalances, -}); - -sample({ - clock: $unstakeForm.fields.signatory.onChange, - source: $signatories, - filter: (signatories) => signatories.length > 0, - fn: (signatories, signatory) => { - const match = signatories[0].find(({ signer }) => signer.id === signatory.id); - - return match?.balance || ZERO_BALANCE; - }, - target: $signatoryBalance, -}); - -sample({ - clock: $unstakeForm.fields.signatory.$value, - fn: (signatory) => [signatory], - target: $selectedSignatories, -}); - -sample({ - clock: $unstakeForm.fields.shards.onChange, - target: $unstakeForm.fields.amount.resetErrors, -}); - -sample({ - clock: $unstakeForm.fields.amount.onChange, - target: $unstakeForm.fields.shards.resetErrors, -}); - -sample({ - clock: $txWrappers.updates, - fn: (txWrappers) => ({ - isProxy: transactionService.hasProxy(txWrappers), - isMultisig: transactionService.hasMultisig(txWrappers), - }), - target: spread({ - isProxy: $isProxy, - isMultisig: $isMultisig, - }), -}); - -sample({ - clock: $realAccounts.updates, - source: { - isProxy: $isProxy, - balances: balanceModel.$balances, - network: $networkStore, - }, - filter: ({ isProxy, network }, accounts) => { - return isProxy && Boolean(network) && accounts.length > 0; - }, - fn: ({ balances, network }, accounts) => { - const balance = balanceUtils.getBalance( - balances, - accounts[0].accountId, - network!.chain.chainId, - network!.asset.assetId.toString(), - ); - - return transferableAmount(balance); - }, - target: $proxyBalance, -}); - -// Submit - -sample({ - clock: $unstakeForm.formValidated, - source: { - realAccounts: $realAccounts, - network: $networkStore, - transactions: $transactions, - isProxy: $isProxy, - fee: $fee, - totalFee: $totalFee, - multisigDeposit: $multisigDeposit, - }, - filter: ({ network, transactions }) => { - return Boolean(network) && Boolean(transactions); - }, - fn: ({ realAccounts, network, transactions, isProxy, ...fee }, formData) => { - const { shards, ...rest } = formData; - - const signatory = formData.signatory.accountId ? formData.signatory : undefined; - // TODO: update after i18n effector integration - const defaultText = `Unstake ${formData.amount} ${network!.asset.symbol}`; - const description = signatory ? formData.description || defaultText : ''; - const amount = formatAmount(rest.amount, network!.asset.precision); - - return { - transactions: transactions!.map((tx) => ({ - wrappedTx: tx.wrappedTx, - multisigTx: tx.multisigTx, - coreTx: tx.coreTx, - })), - formData: { - ...fee, - ...rest, - shards: realAccounts, - amount, - signatory, - description, - ...(isProxy && { proxiedAccount: shards[0] as ProxiedAccount }), - }, - }; - }, - target: formSubmitted, -}); - -sample({ - clock: formSubmitted, - target: attach({ - source: $stakingUnsub, - effect: (unsub) => unsub(), - }), -}); - -sample({ - clock: formCleared, - target: [$unstakeForm.reset, $shards.reinit], -}); - -export const formModel = { - $unstakeForm, - $proxyWallet, - $signatories, - - $accounts, - $accountsBalances, - $unstakeBalanceRange, - $proxyBalance, - - $fee, - $multisigDeposit, - - $api, - $networkStore, - $transactions, - $isMultisig, - $isChainConnected, - $isStakingLoading: subscribeStakingFx.pending, - $canSubmit, - - events: { - formInitiated, - formCleared, - - feeChanged, - totalFeeChanged, - multisigDepositChanged, - isFeeLoadingChanged, - }, - output: { - formSubmitted, - }, -}; diff --git a/src/renderer/widgets/Unstake/model/unstake-model.ts b/src/renderer/widgets/Unstake/model/unstake-model.ts deleted file mode 100644 index 90166ad031..0000000000 --- a/src/renderer/widgets/Unstake/model/unstake-model.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createEvent, createStore, sample, restore } from 'effector'; -import { spread, delay } from 'patronum'; - -import { Transaction } from '@entities/transaction'; -import { signModel } from '@features/operations/OperationSign/model/sign-model'; -import { submitModel } from '@features/operations/OperationSubmit'; -import { Step, UnstakeStore, NetworkStore } from '../lib/types'; -import { formModel } from './form-model'; -import { confirmModel } from './confirm-model'; -import { nonNullable, getRelaychainAsset } from '@shared/lib/utils'; - -const stepChanged = createEvent(); - -const flowStarted = createEvent(); -const flowFinished = createEvent(); - -const $step = createStore(Step.NONE); - -const $unstakeStore = createStore(null); -const $networkStore = restore(flowStarted, null); - -const $wrappedTxs = createStore(null); -const $multisigTxs = createStore(null); -const $coreTxs = createStore(null); - -sample({ clock: stepChanged, target: $step }); - -sample({ - clock: flowStarted, - target: formModel.events.formInitiated, -}); - -sample({ - clock: flowStarted, - fn: () => Step.INIT, - target: stepChanged, -}); - -sample({ - clock: formModel.output.formSubmitted, - fn: ({ transactions, formData }) => { - const wrappedTxs = transactions.map((tx) => tx.wrappedTx); - const multisigTxs = transactions.map((tx) => tx.multisigTx).filter(nonNullable); - const coreTxs = transactions.map((tx) => tx.coreTx); - - return { - wrappedTxs, - multisigTxs: multisigTxs.length === 0 ? null : multisigTxs, - coreTxs, - unstakeStore: formData, - }; - }, - target: spread({ - wrappedTxs: $wrappedTxs, - multisigTxs: $multisigTxs, - coreTxs: $coreTxs, - unstakeStore: $unstakeStore, - }), -}); - -sample({ - clock: formModel.output.formSubmitted, - source: $networkStore, - filter: (network: NetworkStore | null): network is NetworkStore => Boolean(network), - fn: ({ chain }, { formData }) => ({ - event: { ...formData, chain, asset: getRelaychainAsset(chain.assets)! }, - step: Step.CONFIRM, - }), - target: spread({ - event: confirmModel.events.formInitiated, - step: stepChanged, - }), -}); - -sample({ - clock: confirmModel.output.formSubmitted, - source: { - unstakeStore: $unstakeStore, - networkStore: $networkStore, - wrappedTxs: $wrappedTxs, - }, - filter: ({ unstakeStore, networkStore, wrappedTxs }) => { - return Boolean(unstakeStore) && Boolean(networkStore) && Boolean(wrappedTxs); - }, - fn: ({ unstakeStore, networkStore, wrappedTxs }) => ({ - event: { - chain: networkStore!.chain, - accounts: unstakeStore!.shards, - signatory: unstakeStore!.signatory, - transactions: wrappedTxs!, - }, - step: Step.SIGN, - }), - target: spread({ - event: signModel.events.formInitiated, - step: stepChanged, - }), -}); - -sample({ - clock: signModel.output.formSubmitted, - source: { - unstakeStore: $unstakeStore, - networkStore: $networkStore, - multisigTxs: $multisigTxs, - coreTxs: $coreTxs, - }, - filter: (transferData) => { - return Boolean(transferData.unstakeStore) && Boolean(transferData.coreTxs) && Boolean(transferData.networkStore); - }, - fn: (transferData, signParams) => ({ - event: { - ...signParams, - chain: transferData.networkStore!.chain, - account: transferData.unstakeStore!.shards[0], - signatory: transferData.unstakeStore!.signatory, - description: transferData.unstakeStore!.description, - transactions: transferData.coreTxs!, - multisigTxs: transferData.multisigTxs || undefined, - }, - step: Step.SUBMIT, - }), - target: spread({ - event: submitModel.events.formInitiated, - step: stepChanged, - }), -}); - -sample({ - clock: delay(submitModel.output.formSubmitted, 2000), - target: flowFinished, -}); - -sample({ - clock: flowFinished, - fn: () => Step.NONE, - target: [stepChanged, formModel.events.formCleared], -}); - -export const unstakeModel = { - $step, - $networkStore, - events: { - flowStarted, - stepChanged, - }, - output: { - flowFinished, - }, -}; diff --git a/src/renderer/widgets/Unstake/ui/Unstake.tsx b/src/renderer/widgets/Unstake/ui/Unstake.tsx deleted file mode 100644 index ae49d49af2..0000000000 --- a/src/renderer/widgets/Unstake/ui/Unstake.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useUnit } from 'effector-react'; - -import { BaseModal } from '@shared/ui'; -import { useModalClose } from '@shared/lib/hooks'; -import { OperationTitle } from '@entities/chain'; -import { useI18n } from '@app/providers'; -import { OperationSign, OperationSubmit } from '@features/operations'; -import { UnstakeForm } from './UnstakeForm'; -import { Confirmation } from './Confirmation'; -import { unstakeUtils } from '../lib/unstake-utils'; -import { unstakeModel } from '../model/unstake-model'; -import { Step } from '../lib/types'; - -export const Unstake = () => { - const { t } = useI18n(); - - const step = useUnit(unstakeModel.$step); - const networkStore = useUnit(unstakeModel.$networkStore); - - const [isModalOpen, closeModal] = useModalClose(!unstakeUtils.isNoneStep(step), unstakeModel.output.flowFinished); - - if (!networkStore) return null; - - if (unstakeUtils.isSubmitStep(step)) return ; - - return ( - - } - onClose={closeModal} - > - {unstakeUtils.isInitStep(step) && } - {unstakeUtils.isConfirmStep(step) && unstakeModel.events.stepChanged(Step.INIT)} />} - {unstakeUtils.isSignStep(step) && ( - unstakeModel.events.stepChanged(Step.CONFIRM)} /> - )} - - ); -};