diff --git a/nym-wallet/.storybook/main.js b/nym-wallet/.storybook/main.js index db6de0998f..c4a255d616 100644 --- a/nym-wallet/.storybook/main.js +++ b/nym-wallet/.storybook/main.js @@ -9,6 +9,7 @@ module.exports = { core: { builder: 'webpack5', }, + typescript: { reactDocgen: false }, // webpackFinal: async (config, { configType }) => { // // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' // // You can change the configuration based on that. @@ -32,15 +33,17 @@ module.exports = { config.resolve.extensions = ['.tsx', '.ts', '.js']; config.resolve.plugins = [new TsconfigPathsPlugin()]; - config.plugins.push(new ForkTsCheckerWebpackPlugin({ - typescript: { - mode: 'write-references', - diagnosticOptions: { - semantic: true, - syntactic: true, + config.plugins.push( + new ForkTsCheckerWebpackPlugin({ + typescript: { + mode: 'write-references', + diagnosticOptions: { + semantic: true, + syntactic: true, + }, }, - }, - })); + }), + ); if (!config.resolve.alias) { config.resolve.alias = {}; diff --git a/nym-wallet/.storybook/mocks/tauri/app.js b/nym-wallet/.storybook/mocks/tauri/app.js index 39e0fff63f..83d80d7037 100644 --- a/nym-wallet/.storybook/mocks/tauri/app.js +++ b/nym-wallet/.storybook/mocks/tauri/app.js @@ -4,5 +4,5 @@ */ module.exports = { - getVersion: () => undefined -} + getVersion: () => undefined, +}; diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index 97d134db20..01ac86b95c 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -130,6 +130,8 @@ fn main() { simulate::mixnet::simulate_update_mixnode, simulate::mixnet::simulate_delegate_to_mixnode, simulate::mixnet::simulate_undelegate_from_mixnode, + simulate::vesting::simulate_vesting_delegate_to_mixnode, + simulate::vesting::simulate_vesting_undelegate_from_mixnode, simulate::vesting::simulate_vesting_bond_gateway, simulate::vesting::simulate_vesting_unbond_gateway, simulate::vesting::simulate_vesting_bond_mixnode, diff --git a/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs b/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs index 4fbefee216..d00d8c68be 100644 --- a/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs +++ b/nym-wallet/src-tauri/src/operations/simulate/mixnet.rs @@ -150,6 +150,7 @@ pub async fn simulate_undelegate_from_mixnode( identity: &str, state: tauri::State<'_, Arc>>, ) -> Result { + println!("Called"); let guard = state.read().await; let client = guard.current_client()?; let mixnet_contract = client.nymd.mixnet_contract_address(); diff --git a/nym-wallet/src-tauri/src/operations/simulate/vesting.rs b/nym-wallet/src-tauri/src/operations/simulate/vesting.rs index f01ca16fe9..fc9779ddf0 100644 --- a/nym-wallet/src-tauri/src/operations/simulate/vesting.rs +++ b/nym-wallet/src-tauri/src/operations/simulate/vesting.rs @@ -126,6 +126,53 @@ pub async fn simulate_vesting_update_mixnode( Ok(detailed_fee(client, result)) } +#[tauri::command] +pub async fn simulate_vesting_delegate_to_mixnode( + identity: &str, + amount: MajorCurrencyAmount, + state: tauri::State<'_, Arc>>, +) -> Result { + let guard = state.read().await; + let amount = amount.into(); + + let client = guard.current_client()?; + let vesting_contract = client.nymd.vesting_contract_address(); + + let msg = client.nymd.wrap_contract_execute_message( + vesting_contract, + &ExecuteMsg::DelegateToMixnode { + mix_identity: identity.to_string(), + amount, + }, + vec![], + )?; + + let result = client.nymd.simulate(vec![msg]).await?; + Ok(detailed_fee(client, result)) +} + +#[tauri::command] +pub async fn simulate_vesting_undelegate_from_mixnode( + identity: &str, + state: tauri::State<'_, Arc>>, +) -> Result { + let guard = state.read().await; + + let client = guard.current_client()?; + let vesting_contract = client.nymd.vesting_contract_address(); + + let msg = client.nymd.wrap_contract_execute_message( + vesting_contract, + &ExecuteMsg::UndelegateFromMixnode { + mix_identity: identity.to_string(), + }, + vec![], + )?; + + let result = client.nymd.simulate(vec![msg]).await?; + Ok(detailed_fee(client, result)) +} + #[tauri::command] pub async fn simulate_withdraw_vested_coins( amount: MajorCurrencyAmount, diff --git a/nym-wallet/src/components/ConfirmTX.stories.tsx b/nym-wallet/src/components/ConfirmTX.stories.tsx new file mode 100644 index 0000000000..27370aefc9 --- /dev/null +++ b/nym-wallet/src/components/ConfirmTX.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ConfirmTx } from './ConfirmTX'; +import { ModalListItem } from './Modals/ModalListItem'; + +export default { + title: 'Wallet / Confirm Transaction', + component: ConfirmTx, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + + + + + +); + +export const Default = Template.bind({}); +Default.args = { + open: true, + header: 'Confirm transaction', + subheader: 'Confirm and proceed or cancel transaction', + fee: { amount: { amount: '0.001', denom: 'NYM' }, fee: { Auto: null } }, + onClose: () => {}, + onConfirm: async () => {}, + onPrev: () => {}, +}; diff --git a/nym-wallet/src/components/ConfirmTX.tsx b/nym-wallet/src/components/ConfirmTX.tsx new file mode 100644 index 0000000000..c398e2e879 --- /dev/null +++ b/nym-wallet/src/components/ConfirmTX.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { FeeDetails } from '@nymproject/types'; +import { Box, Button } from '@mui/material'; +import { SimpleModal } from './Modals/SimpleModal'; +import { ModalFee } from './Modals/ModalFee'; + +export const ConfirmTx: React.FC<{ + open: boolean; + header: string; + subheader?: string; + fee: FeeDetails; + onConfirm: () => Promise; + onClose?: () => void; + onPrev: () => void; +}> = ({ open, fee, onConfirm, onClose, header, subheader, onPrev, children }) => ( + + Cancel + + } + > + + {children} + + + +); diff --git a/nym-wallet/src/components/Delegation/DelegateBlocker.tsx b/nym-wallet/src/components/Delegation/DelegateBlocker.tsx index fc7b71d257..1733a94dcb 100644 --- a/nym-wallet/src/components/Delegation/DelegateBlocker.tsx +++ b/nym-wallet/src/components/Delegation/DelegateBlocker.tsx @@ -13,7 +13,7 @@ export const OverSaturatedBlockerModal: React.FC<{ hideCloseIcon displayErrorIcon onClose={onClose} - onOk={onClose} + onOk={async () => onClose?.()} header={header || 'Delegate'} subHeader={subHeader || "This node is over saturated, you can't delegate more stake to it"} okLabel={buttonText || 'Close'} diff --git a/nym-wallet/src/components/Delegation/DelegateModal.tsx b/nym-wallet/src/components/Delegation/DelegateModal.tsx index 83948d8924..5313246187 100644 --- a/nym-wallet/src/components/Delegation/DelegateModal.tsx +++ b/nym-wallet/src/components/Delegation/DelegateModal.tsx @@ -1,13 +1,17 @@ -import React, { useEffect, useState } from 'react'; -import { Box, Stack, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { Box, Typography } from '@mui/material'; import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; import { CurrencyFormField } from '@nymproject/react/currency/CurrencyFormField'; -import { CurrencyDenom, MajorCurrencyAmount } from '@nymproject/types'; -import { getGasFee } from 'src/requests'; +import { CurrencyDenom, FeeDetails, MajorCurrencyAmount } from '@nymproject/types'; import { Console } from 'src/utils/console'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { simulateDelegateToMixnode, simulateVestingDelegateToMixnode } from 'src/requests'; import { SimpleModal } from '../Modals/SimpleModal'; -import { ModalListItem } from './ModalListItem'; +import { ModalListItem } from '../Modals/ModalListItem'; +import { checkTokenBalance, validateAmount, validateKey } from '../../utils'; import { TokenPoolSelector, TPoolOption } from '../TokenPoolSelector'; +import { ConfirmTx } from '../ConfirmTX'; + import { getMixnodeStakeSaturation } from '../../requests'; const MIN_AMOUNT_TO_DELEGATE = 10; @@ -15,7 +19,7 @@ const MIN_AMOUNT_TO_DELEGATE = 10; export const DelegateModal: React.FC<{ open: boolean; onClose?: () => void; - onOk?: (identityKey: string, amount: MajorCurrencyAmount, tokenPool: TPoolOption) => Promise; + onOk?: (identityKey: string, amount: MajorCurrencyAmount, tokenPool: TPoolOption, fee?: FeeDetails) => Promise; identityKey?: string; onIdentityKeyChanged?: (identityKey: string) => void; onAmountChanged?: (amount: string) => void; @@ -26,7 +30,6 @@ export const DelegateModal: React.FC<{ estimatedReward?: number; profitMarginPercentage?: number | null; nodeUptimePercentage?: number | null; - feeOverride?: string; currency: CurrencyDenom; initialAmount?: string; hasVestingContract: boolean; @@ -41,7 +44,6 @@ export const DelegateModal: React.FC<{ identityKey: initialIdentityKey, rewardInterval, accountBalance, - feeOverride, estimatedReward, currency, profitMarginPercentage, @@ -51,66 +53,89 @@ export const DelegateModal: React.FC<{ }) => { const [identityKey, setIdentityKey] = useState(initialIdentityKey); const [amount, setAmount] = useState(initialAmount); - const [fee, setFee] = useState(); - const [tokenPool, setTokenPool] = useState('balance'); const [isValidated, setValidated] = useState(false); - const [errorAmount, setErrorAmount] = useState(); - const [errorNodeSaturation, setErrorNodeSaturation] = useState(); + const [errorAmount, setErrorAmount] = useState(); + const [tokenPool, setTokenPool] = useState('balance'); const [errorIdentityKey, setErrorIdentityKey] = useState(); - const getFee = async () => { - if (feeOverride) setFee(feeOverride); - else { - const res = await getGasFee('BondMixnode'); - setFee(res.amount); - } - }; + const { fee, getFee, resetFeeState } = useGetFee(); const handleCheckStakeSaturation = async (identity: string) => { - setErrorNodeSaturation(undefined); - try { const newSaturation = await getMixnodeStakeSaturation(identity); if (newSaturation && newSaturation.saturation > 1) { const saturationPercentage = Math.round(newSaturation.saturation * 100); - setErrorNodeSaturation(`This node is over saturated (${saturationPercentage}%), please select another node`); + return { isOverSaturated: true, saturationPercentage }; } + return { isOverSaturated: false, saturationPercentage: undefined }; } catch (e) { Console.error('Error fetching the saturation, error:', e); - setErrorNodeSaturation(undefined); + return { isOverSaturated: false, saturationPercentage: undefined }; } }; - const validateIdentityKey = (isValid: boolean) => { - if (!isValid) { - setErrorIdentityKey('Identity key is invalid'); - setErrorNodeSaturation(undefined); - } else { - setErrorIdentityKey(undefined); + const validate = async () => { + let newValidatedValue = true; + let errorAmountMessage; + let errorIdentityKeyMessage; + + if (!identityKey || !validateKey(identityKey, 32)) { + newValidatedValue = false; + errorIdentityKeyMessage = undefined; } + + if (identityKey && validateKey(identityKey, 32)) { + const { isOverSaturated, saturationPercentage } = await handleCheckStakeSaturation(identityKey); + if (isOverSaturated) { + newValidatedValue = false; + errorIdentityKeyMessage = `This node is over saturated (${saturationPercentage}%), please select another node`; + } + } + + if (amount && !(await validateAmount(amount, '0'))) { + newValidatedValue = false; + errorAmountMessage = 'Please enter a valid amount'; + } + + if (amount && Number(amount) < MIN_AMOUNT_TO_DELEGATE) { + errorAmountMessage = `Min. delegation amount: ${MIN_AMOUNT_TO_DELEGATE} ${currency}`; + newValidatedValue = false; + } + + if (!amount?.length) { + newValidatedValue = false; + } + + setErrorIdentityKey(errorIdentityKeyMessage); + setErrorAmount(errorAmountMessage); + setValidated(newValidatedValue); }; - const validateAmount = (newValue: MajorCurrencyAmount) => { - setErrorAmount(undefined); - - if (newValue.amount && Number(newValue.amount) < MIN_AMOUNT_TO_DELEGATE) { - setErrorAmount(`Min. delegation amount: ${MIN_AMOUNT_TO_DELEGATE} ${currency}`); - } - - if (!newValue.amount) { - setErrorAmount('Amount required'); - } - }; - - const handleOk = () => { + const handleOk = async () => { if (onOk && amount && identityKey) { - onOk(identityKey, { amount, denom: currency }, tokenPool); + onOk(identityKey, { amount, denom: currency }, tokenPool, fee); + } + }; + + const handleConfirm = async ({ identity, value }: { identity: string; value: MajorCurrencyAmount }) => { + const hasEnoughTokens = await checkTokenBalance(tokenPool, value.amount); + + if (!hasEnoughTokens) { + setErrorAmount('Not enough funds'); + return; + } + + if (tokenPool === 'balance') { + getFee(simulateDelegateToMixnode, { identity, amount: value }); + } + + if (tokenPool === 'locked') { + getFee(simulateVestingDelegateToMixnode, { identity, amount: value }); } }; const handleIdentityKeyChanged = (newIdentityKey: string) => { setIdentityKey(newIdentityKey); - handleCheckStakeSaturation(newIdentityKey); if (onIdentityKeyChanged) { onIdentityKeyChanged(newIdentityKey); @@ -119,27 +144,41 @@ export const DelegateModal: React.FC<{ const handleAmountChanged = (newAmount: MajorCurrencyAmount) => { setAmount(newAmount.amount); - validateAmount(newAmount); if (onAmountChanged) { onAmountChanged(newAmount.amount); } }; - useEffect(() => { - getFee(); - }, []); + React.useEffect(() => { + validate(); + }, [amount, identityKey]); - useEffect(() => { - if (!!errorIdentityKey || !!errorAmount || errorNodeSaturation) setValidated(false); - else setValidated(true); - }, [errorIdentityKey, errorAmount, errorNodeSaturation]); + if (fee) { + return ( + + + + + ); + } return ( { + if (identityKey && amount) { + handleConfirm({ identity: identityKey, value: { amount, denom: currency } }); + } + }} header={header || 'Delegate'} subHeader="Delegate to mixnode" okLabel={buttonText || 'Delegate stake'} @@ -150,8 +189,7 @@ export const DelegateModal: React.FC<{ fullWidth placeholder="Node identity key" onChanged={handleIdentityKeyChanged} - onValidate={validateIdentityKey} - initialValue={initialIdentityKey} + initialValue={identityKey} readOnly={Boolean(initialIdentityKey)} textFieldProps={{ autoFocus: !initialIdentityKey, @@ -163,7 +201,7 @@ export const DelegateModal: React.FC<{ variant="caption" sx={{ color: 'error.main', mx: '14px', mt: '3px' }} > - {errorNodeSaturation} + {errorIdentityKey} {hasVestingContract && setTokenPool(pool)} />} @@ -171,7 +209,7 @@ export const DelegateModal: React.FC<{ required fullWidth placeholder="Amount" - initialValue={initialAmount} + initialValue={amount} autoFocus={Boolean(initialIdentityKey)} onChanged={handleAmountChanged} /> @@ -184,10 +222,10 @@ export const DelegateModal: React.FC<{ > {errorAmount} - - Account balance - {accountBalance} - + + + + ); }; diff --git a/nym-wallet/src/components/Delegation/Modals.stories.tsx b/nym-wallet/src/components/Delegation/Modals.stories.tsx index 72d7171aa3..71742826c9 100644 --- a/nym-wallet/src/components/Delegation/Modals.stories.tsx +++ b/nym-wallet/src/components/Delegation/Modals.stories.tsx @@ -51,7 +51,6 @@ export const Delegate = () => { onClose={() => setOpen(false)} onOk={async () => setOpen(false)} currency="NYM" - feeOverride="0.004375" estimatedReward={50.423} accountBalance="425.2345053" nodeUptimePercentage={99.28394} @@ -73,7 +72,6 @@ export const DelegateBelowMinimum = () => { onClose={() => setOpen(false)} onOk={async () => setOpen(false)} currency="NYM" - feeOverride="0.004375" estimatedReward={425.2345053} nodeUptimePercentage={99.28394} profitMarginPercentage={11.12334234} @@ -97,7 +95,6 @@ export const DelegateMore = () => { header="Delegate more" buttonText="Delegate more" currency="NYM" - feeOverride="0.004375" estimatedReward={50.423} accountBalance="425.2345053" nodeUptimePercentage={99.28394} @@ -117,9 +114,8 @@ export const Undelegate = () => { setOpen(false)} - onOk={() => setOpen(false)} + onOk={async () => setOpen(false)} currency="NYM" - fee={0.004375} amount={150} identityKey="AA6RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujyxx" usesVestingContractTokens={false} diff --git a/nym-wallet/src/components/Delegation/UndelegateModal.tsx b/nym-wallet/src/components/Delegation/UndelegateModal.tsx index 80b37f642c..83fc826d0c 100644 --- a/nym-wallet/src/components/Delegation/UndelegateModal.tsx +++ b/nym-wallet/src/components/Delegation/UndelegateModal.tsx @@ -1,23 +1,37 @@ -import React from 'react'; -import { Stack, Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; +import React, { useEffect } from 'react'; +import { FeeDetails } from '@nymproject/types'; +import { useGetFee } from 'src/hooks/useGetFee'; +import { simulateUndelegateFromMixnode, simulateVestingUndelegateFromMixnode } from 'src/requests'; +import { ModalFee } from '../Modals/ModalFee'; +import { ModalListItem } from '../Modals/ModalListItem'; import { SimpleModal } from '../Modals/SimpleModal'; export const UndelegateModal: React.FC<{ open: boolean; onClose?: () => void; - onOk?: (identityKey: string, usesVestingContractTokens: boolean) => void; + onOk?: (identityKey: string, usesVestingContractTokens: boolean, fee?: FeeDetails) => void; identityKey: string; amount: number; - fee: number; currency: string; usesVestingContractTokens: boolean; -}> = ({ identityKey, open, onClose, onOk, amount, fee, currency, usesVestingContractTokens }) => { - const handleOk = () => { +}> = ({ identityKey, open, onClose, onOk, amount, currency, usesVestingContractTokens }) => { + const { fee, isFeeLoading, feeError, getFee } = useGetFee(); + + useEffect(() => { + if (usesVestingContractTokens) getFee(simulateVestingUndelegateFromMixnode, { identity: identityKey }); + else { + getFee(simulateUndelegateFromMixnode, identityKey); + } + }, []); + + const handleOk = async () => { if (onOk) { - onOk(identityKey, usesVestingContractTokens); + onOk(identityKey, usesVestingContractTokens, fee); } }; + return ( - - Delegation amount: - - {amount} {currency} - - + + + Tokens will be transferred to account you are logged in with now - - theme.palette.nym.fee}> - Est. fee for this transaction: - - theme.palette.nym.fee}> - {fee} {currency} - - + ); }; diff --git a/nym-wallet/src/components/Modals/ModalFee.tsx b/nym-wallet/src/components/Modals/ModalFee.tsx new file mode 100644 index 0000000000..c64fa0286e --- /dev/null +++ b/nym-wallet/src/components/Modals/ModalFee.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { FeeDetails } from '@nymproject/types'; +import { CircularProgress } from '@mui/material'; +import { ModalListItem } from './ModalListItem'; + +type TFeeProps = { fee?: FeeDetails; isLoading: boolean; error?: string }; + +const getValue = ({ fee, isLoading, error }: TFeeProps) => { + if (isLoading) return ; + if (error && !isLoading) return 'n/a'; + if (fee) return `${fee.amount?.amount} ${fee.amount?.denom}`; + return '-'; +}; + +export const ModalFee = ({ fee, isLoading, error }: TFeeProps) => ( + +); diff --git a/nym-wallet/src/components/Delegation/ModalListItem.tsx b/nym-wallet/src/components/Modals/ModalListItem.tsx similarity index 86% rename from nym-wallet/src/components/Delegation/ModalListItem.tsx rename to nym-wallet/src/components/Modals/ModalListItem.tsx index 24da1f7506..a416375200 100644 --- a/nym-wallet/src/components/Delegation/ModalListItem.tsx +++ b/nym-wallet/src/components/Modals/ModalListItem.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { Box, Stack, Typography } from '@mui/material'; -import { ModalDivider } from '../Modals/ModalDivider'; +import { ModalDivider } from './ModalDivider'; export const ModalListItem: React.FC<{ label: string; divider?: boolean; hidden?: boolean; - value: React.ReactNode; + value: string | React.ReactNode; }> = ({ label, value, hidden, divider }) => ( ); diff --git a/nym-wallet/src/components/Modals/styles.ts b/nym-wallet/src/components/Modals/styles.ts index de4621a2a8..1f3c92cb09 100644 --- a/nym-wallet/src/components/Modals/styles.ts +++ b/nym-wallet/src/components/Modals/styles.ts @@ -3,7 +3,7 @@ export const modalStyle = { top: '50%', left: '50%', transform: 'translate(-50%, -50%)', - width: 500, + width: 600, bgcolor: 'background.paper', boxShadow: 24, borderRadius: '16px', diff --git a/nym-wallet/src/components/Rewards/CompoundModal.tsx b/nym-wallet/src/components/Rewards/CompoundModal.tsx index 9fb67f7e00..7b90e3a01b 100644 --- a/nym-wallet/src/components/Rewards/CompoundModal.tsx +++ b/nym-wallet/src/components/Rewards/CompoundModal.tsx @@ -1,24 +1,40 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Alert, AlertTitle, Stack, Typography } from '@mui/material'; -import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; import WarningIcon from '@mui/icons-material/Warning'; +import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; +import { simulateCompoundDelgatorReward, simulateVestingCompoundDelgatorReward } from 'src/requests'; +import { isGreaterThan } from 'src/utils'; +import { useGetFee } from 'src/hooks/useGetFee'; import { SimpleModal } from '../Modals/SimpleModal'; +import { ModalFee } from '../Modals/ModalFee'; +import { FeeDetails } from '@nymproject/types'; export const CompoundModal: React.FC<{ open: boolean; onClose?: () => void; - onOk?: (identityKey: string) => void; + onOk?: (identityKey: string, fee?: FeeDetails) => void; identityKey: string; amount: number; - fee: number; + minimum?: number; currency: string; message: string; -}> = ({ open, onClose, onOk, identityKey, amount, fee, currency, message }) => { - const handleOk = () => { + usesVestingTokens: boolean; +}> = ({ open, onClose, onOk, identityKey, amount, currency, message, usesVestingTokens }) => { + const { fee, isFeeLoading, feeError, getFee } = useGetFee(); + + const handleOk = async () => { if (onOk) { - onOk(identityKey); + onOk(identityKey, fee); } }; + + useEffect(() => { + if (usesVestingTokens) getFee(simulateVestingCompoundDelgatorReward, identityKey); + else { + getFee(simulateCompoundDelgatorReward, identityKey); + } + }, []); + return ( - - theme.palette.nym.fee}> - Est. fee for this transaction: - - theme.palette.nym.fee}> - {fee} {currency} - - - - {amount < fee && ( + + {fee?.amount && isGreaterThan(+fee.amount.amount, amount) && ( }> Warning: fees are greater than the reward The fees for redeeming rewards will cost more than the rewards. Are you sure you want to continue? diff --git a/nym-wallet/src/components/Rewards/RedeemModal.stories.tsx b/nym-wallet/src/components/Rewards/RedeemModal.stories.tsx index f483397c35..1a50c84f6c 100644 --- a/nym-wallet/src/components/Rewards/RedeemModal.stories.tsx +++ b/nym-wallet/src/components/Rewards/RedeemModal.stories.tsx @@ -52,12 +52,12 @@ export const RedeemAllRewards = () => { setOpen(false)} - onOk={() => setOpen(false)} + onOk={async () => setOpen(false)} message="Redeem all rewards" currency="NYM" identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa" - fee={0.004375} amount={425.65843} + usesVestingTokens={false} /> ); @@ -71,12 +71,12 @@ export const RedeemRewardForMixnode = () => { setOpen(false)} - onOk={() => setOpen(false)} + onOk={async () => setOpen(false)} message="Redeem rewards" currency="NYM" identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa" - fee={0.004375} amount={425.65843} + usesVestingTokens={false} /> ); @@ -94,8 +94,8 @@ export const FeeIsMoreThanAllRewards = () => { message="Redeem all rewards" currency="NYM" identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa" - fee={0.004375} amount={0.001} + usesVestingTokens={false} /> ); @@ -109,12 +109,12 @@ export const FeeIsMoreThanMixnodeReward = () => { setOpen(false)} - onOk={() => setOpen(false)} + onOk={async () => setOpen(false)} identityKey="D88RfeY8DttMD3CQKoayV6mss5a5FC3RoH75Kmcujaaa" message="Redeem rewards" currency="NYM" - fee={0.004375} amount={0.001} + usesVestingTokens={false} /> ); diff --git a/nym-wallet/src/components/Rewards/RedeemModal.tsx b/nym-wallet/src/components/Rewards/RedeemModal.tsx index 9478b5a8d0..8ac05d7ab3 100644 --- a/nym-wallet/src/components/Rewards/RedeemModal.tsx +++ b/nym-wallet/src/components/Rewards/RedeemModal.tsx @@ -1,24 +1,41 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Alert, AlertTitle, Stack, Typography } from '@mui/material'; import { IdentityKeyFormField } from '@nymproject/react/mixnodes/IdentityKeyFormField'; import WarningIcon from '@mui/icons-material/Warning'; +import { simulateClaimDelgatorReward, simulateVestingClaimDelgatorReward } from 'src/requests'; +import { isGreaterThan } from 'src/utils'; +import { useGetFee } from 'src/hooks/useGetFee'; import { SimpleModal } from '../Modals/SimpleModal'; +import { ModalFee } from '../Modals/ModalFee'; +import { FeeDetails } from '@nymproject/types'; export const RedeemModal: React.FC<{ open: boolean; onClose?: () => void; - onOk?: (identityKey: string) => void; + onOk?: (identityKey: string, fee?: FeeDetails) => void; identityKey: string; amount: number; - fee: number; + minimum?: number; currency: string; message: string; -}> = ({ open, onClose, onOk, identityKey, amount, fee, currency, message }) => { - const handleOk = () => { + usesVestingTokens: boolean; +}> = ({ open, onClose, onOk, identityKey, amount, currency, message, usesVestingTokens }) => { + const { fee, isFeeLoading, feeError, getFee } = useGetFee(); + + const handleOk = async () => { if (onOk) { - onOk(identityKey); + onOk(identityKey, fee); } }; + + useEffect(() => { + if (usesVestingTokens) { + getFee(simulateVestingClaimDelgatorReward, identityKey); + } else { + getFee(simulateClaimDelgatorReward, identityKey); + } + }, []); + return ( - - theme.palette.nym.fee}> - Est. fee for this transaction: - - theme.palette.nym.fee}> - {fee} {currency} - - - - {amount < fee && ( + + {fee?.amount && isGreaterThan(+fee.amount.amount, amount) && ( }> Warning: fees are greater than the reward The fees for redeeming rewards will cost more than the rewards. Are you sure you want to continue? diff --git a/nym-wallet/src/context/delegations.tsx b/nym-wallet/src/context/delegations.tsx index 5e21fab1d9..166c66c8bc 100644 --- a/nym-wallet/src/context/delegations.tsx +++ b/nym-wallet/src/context/delegations.tsx @@ -3,6 +3,7 @@ import { getDelegationSummary, undelegateAllFromMixnode } from 'src/requests/del import { DelegationEvent, DelegationWithEverything, + FeeDetails, MajorCurrencyAmount, TransactionExecuteResult, } from '@nymproject/types'; @@ -21,8 +22,13 @@ export type TDelegationContext = { addDelegation: ( data: { identity: string; amount: MajorCurrencyAmount }, tokenPool: TPoolOption, + fee?: FeeDetails, ) => Promise; - undelegate: (identity: string, usesVestingContractTokens: boolean) => Promise; + undelegate: ( + identity: string, + usesVestingContractTokens: boolean, + fee?: FeeDetails, + ) => Promise; }; export type TDelegationTransaction = { @@ -50,7 +56,11 @@ export const DelegationContextProvider: FC<{ const [totalRewards, setTotalRewards] = useState(); const [pendingDelegations, setPendingDelegations] = useState(); - const addDelegation = async (data: { identity: string; amount: MajorCurrencyAmount }, tokenPool: TPoolOption) => { + const addDelegation = async ( + data: { identity: string; amount: MajorCurrencyAmount }, + tokenPool: TPoolOption, + fee?: FeeDetails, + ) => { try { let tx; diff --git a/nym-wallet/src/context/rewards.tsx b/nym-wallet/src/context/rewards.tsx index d062ca545b..4f93c88ea1 100644 --- a/nym-wallet/src/context/rewards.tsx +++ b/nym-wallet/src/context/rewards.tsx @@ -1,6 +1,6 @@ import React, { createContext, FC, useContext, useEffect, useMemo, useState } from 'react'; import { Network } from 'src/types'; -import { TransactionExecuteResult } from '@nymproject/types'; +import { FeeDetails, TransactionExecuteResult } from '@nymproject/types'; import { useDelegationContext } from './delegations'; import { claimDelegatorRewards, compoundDelegatorRewards } from '../requests'; @@ -9,8 +9,8 @@ type TRewardsContext = { error?: string; totalRewards?: string; refresh: () => Promise; - claimRewards: (identity: string) => Promise; - compoundRewards: (identity: string) => Promise; + claimRewards: (identity: string, fee?: FeeDetails) => Promise; + compoundRewards: (identity: string, fee?: FeeDetails) => Promise; }; export type TRewardsTransaction = { diff --git a/nym-wallet/src/hooks/useGetFee.ts b/nym-wallet/src/hooks/useGetFee.ts new file mode 100644 index 0000000000..a38179c7f4 --- /dev/null +++ b/nym-wallet/src/hooks/useGetFee.ts @@ -0,0 +1,35 @@ +import { FeeDetails } from '@nymproject/types'; +import { useState } from 'react'; +import { Console } from 'src/utils/console'; + +export function useGetFee() { + const [fee, setFee] = useState(); + const [isFeeLoading, setIsFeeLoading] = useState(false); + const [feeError, setFeeError] = useState(); + + async function getFee(operation: (args: T) => Promise, args: T) { + try { + setIsFeeLoading(true); + const simulatedFee = await operation(args); + setFee(simulatedFee); + } catch (e) { + Console.error(e); + setFeeError(e as string); + } + setIsFeeLoading(false); + } + + const resetFeeState = () => { + setFee(undefined); + setIsFeeLoading(false); + setFeeError(undefined); + }; + + return { + fee, + isFeeLoading, + feeError, + getFee, + resetFeeState, + }; +} diff --git a/nym-wallet/src/pages/delegation/index.tsx b/nym-wallet/src/pages/delegation/index.tsx index 6548df9513..bfaf58df6c 100644 --- a/nym-wallet/src/pages/delegation/index.tsx +++ b/nym-wallet/src/pages/delegation/index.tsx @@ -1,11 +1,12 @@ import React, { FC, useContext, useEffect, useState } from 'react'; import { Box, Button, Paper, Stack, Typography } from '@mui/material'; -import { DelegationWithEverything, MajorCurrencyAmount } from '@nymproject/types'; +import { DelegationWithEverything, FeeDetails, MajorCurrencyAmount } from '@nymproject/types'; import { Link } from '@nymproject/react/link/Link'; import { AppContext, urls } from 'src/context/main'; import { DelegationList } from 'src/components/Delegation/DelegationList'; import { PendingEvents } from 'src/components/Delegation/PendingEvents'; import { TPoolOption } from 'src/components'; +import { Console } from 'src/utils/console'; import { CompoundModal } from 'src/components/Rewards/CompoundModal'; import { OverSaturatedBlockerModal } from 'src/components/Delegation/DelegateBlocker'; import { getSpendableCoins, userBalance } from 'src/requests'; @@ -17,7 +18,6 @@ import { UndelegateModal } from '../../components/Delegation/UndelegateModal'; import { DelegationListItemActions } from '../../components/Delegation/DelegationActions'; import { RedeemModal } from '../../components/Rewards/RedeemModal'; import { DelegationModal, DelegationModalProps } from '../../components/Delegation/DelegationModal'; -import { Console } from '../../utils/console'; export const Delegation: FC = () => { const [showNewDelegationModal, setShowNewDelegationModal] = useState(false); @@ -99,7 +99,12 @@ export const Delegation: FC = () => { } }; - const handleNewDelegation = async (identityKey: string, amount: MajorCurrencyAmount, tokenPool: TPoolOption) => { + const handleNewDelegation = async ( + identityKey: string, + amount: MajorCurrencyAmount, + tokenPool: TPoolOption, + fee?: FeeDetails, + ) => { setConfirmationModalProps({ status: 'loading', action: 'delegate', @@ -113,6 +118,7 @@ export const Delegation: FC = () => { amount, }, tokenPool, + fee, ); const balances = await getAllBalances(); @@ -136,7 +142,12 @@ export const Delegation: FC = () => { } }; - const handleDelegateMore = async (identityKey: string, amount: MajorCurrencyAmount, tokenPool: TPoolOption) => { + const handleDelegateMore = async ( + identityKey: string, + amount: MajorCurrencyAmount, + tokenPool: TPoolOption, + fee?: FeeDetails, + ) => { if (currentDelegationListActionItem?.node_identity !== identityKey || !clientDetails) { setConfirmationModalProps({ status: 'error', @@ -159,6 +170,7 @@ export const Delegation: FC = () => { amount, }, tokenPool, + fee, ); const balances = await getAllBalances(); @@ -180,7 +192,7 @@ export const Delegation: FC = () => { } }; - const handleUndelegate = async (identityKey: string, usesVestingContractTokens: boolean) => { + const handleUndelegate = async (identityKey: string, usesVestingContractTokens: boolean, fee?: FeeDetails) => { setConfirmationModalProps({ status: 'loading', action: 'undelegate', @@ -189,7 +201,7 @@ export const Delegation: FC = () => { setCurrentDelegationListActionItem(undefined); try { - const txs = await undelegate(identityKey, usesVestingContractTokens); + const txs = await undelegate(identityKey, usesVestingContractTokens, fee); const balances = await getAllBalances(); setConfirmationModalProps({ @@ -211,7 +223,7 @@ export const Delegation: FC = () => { } }; - const handleRedeem = async (identityKey: string) => { + const handleRedeem = async (identityKey: string, fee?: FeeDetails) => { setConfirmationModalProps({ status: 'loading', action: 'redeem', @@ -220,7 +232,7 @@ export const Delegation: FC = () => { setCurrentDelegationListActionItem(undefined); try { - const txs = await claimRewards(identityKey); + const txs = await claimRewards(identityKey, fee); const bal = await userBalance(); setConfirmationModalProps({ status: 'success', @@ -241,7 +253,7 @@ export const Delegation: FC = () => { } }; - const handleCompound = async (identityKey: string) => { + const handleCompound = async (identityKey: string, fee?: FeeDetails) => { setConfirmationModalProps({ status: 'loading', action: 'compound', @@ -250,7 +262,7 @@ export const Delegation: FC = () => { setCurrentDelegationListActionItem(undefined); try { - const txs = await compoundRewards(identityKey); + const txs = await compoundRewards(identityKey, fee); const bal = await userBalance(); setConfirmationModalProps({ status: 'success', @@ -336,7 +348,6 @@ export const Delegation: FC = () => { buttonText="Delegate more" identityKey={currentDelegationListActionItem.node_identity} currency={clientDetails!.denom} - estimatedReward={0} accountBalance={balance?.printable_balance} nodeUptimePercentage={currentDelegationListActionItem.avg_uptime_percent} profitMarginPercentage={currentDelegationListActionItem.profit_margin_percent} @@ -352,7 +363,6 @@ export const Delegation: FC = () => { onOk={handleUndelegate} usesVestingContractTokens={currentDelegationListActionItem.uses_vesting_contract_tokens} currency={currentDelegationListActionItem.amount.denom} - fee={0.1} amount={+currentDelegationListActionItem.amount.amount} identityKey={currentDelegationListActionItem.node_identity} /> @@ -362,12 +372,12 @@ export const Delegation: FC = () => { setShowRedeemRewardsModal(false)} - onOk={(identity) => handleRedeem(identity)} + onOk={(identity, fee) => handleRedeem(identity, fee)} message="Redeem rewards" currency={clientDetails!.denom} identityKey={currentDelegationListActionItem?.node_identity} - fee={0.004375} amount={+currentDelegationListActionItem.accumulated_rewards.amount} + usesVestingTokens={currentDelegationListActionItem.uses_vesting_contract_tokens} /> )} @@ -375,12 +385,12 @@ export const Delegation: FC = () => { setShowCompoundRewardsModal(false)} - onOk={(identity) => handleCompound(identity)} + onOk={(identity, fee) => handleCompound(identity, fee)} message="Compound rewards" currency={clientDetails!.denom} identityKey={currentDelegationListActionItem?.node_identity} - fee={0.004375} amount={+currentDelegationListActionItem.accumulated_rewards.amount} + usesVestingTokens={currentDelegationListActionItem.uses_vesting_contract_tokens} /> )} diff --git a/nym-wallet/src/requests/delegation.ts b/nym-wallet/src/requests/delegation.ts index 66ea0b65c6..e26f8a6360 100644 --- a/nym-wallet/src/requests/delegation.ts +++ b/nym-wallet/src/requests/delegation.ts @@ -3,6 +3,7 @@ import { DelegationsSummaryResponse, TransactionExecuteResult, MajorCurrencyAmount, + FeeDetails, } from '@nymproject/types'; import { invokeWrapper } from './wrapper'; @@ -14,8 +15,16 @@ export const getDelegationSummary = async () => invokeWrapper invokeWrapper('undelegate_from_mixnode', { identity }); -export const undelegateAllFromMixnode = async (identity: string, usesVestingContractTokens: boolean) => - invokeWrapper('undelegate_all_from_mixnode', { identity, usesVestingContractTokens }); +export const undelegateAllFromMixnode = async ( + identity: string, + usesVestingContractTokens: boolean, + fee?: FeeDetails, +) => + invokeWrapper('undelegate_all_from_mixnode', { + identity, + usesVestingContractTokens, + fee: fee?.fee, + }); export const delegateToMixnode = async ({ identity, amount }: { identity: string; amount: MajorCurrencyAmount }) => invokeWrapper('delegate_to_mixnode', { identity, amount }); diff --git a/nym-wallet/src/requests/index.ts b/nym-wallet/src/requests/index.ts index dfd4c57f11..aec40a78b4 100644 --- a/nym-wallet/src/requests/index.ts +++ b/nym-wallet/src/requests/index.ts @@ -7,3 +7,4 @@ export * from './queries'; export * from './utils'; export * from './delegation'; export * from './rewards'; +export * from './simulate'; diff --git a/nym-wallet/src/requests/rewards.ts b/nym-wallet/src/requests/rewards.ts index 75950ad8f4..1254ebee83 100644 --- a/nym-wallet/src/requests/rewards.ts +++ b/nym-wallet/src/requests/rewards.ts @@ -1,4 +1,4 @@ -import { TransactionExecuteResult } from '@nymproject/types'; +import { FeeDetails, TransactionExecuteResult } from '@nymproject/types'; import { invokeWrapper } from './wrapper'; export const claimOperatorRewards = async () => invokeWrapper('claim_operator_reward'); @@ -6,8 +6,14 @@ export const claimOperatorRewards = async () => invokeWrapper invokeWrapper('compound_operator_reward'); -export const claimDelegatorRewards = async (mixIdentity: string) => - invokeWrapper('claim_locked_and_unlocked_delegator_reward', { mixIdentity }); +export const claimDelegatorRewards = async (mixIdentity: string, fee?: FeeDetails) => + invokeWrapper('claim_locked_and_unlocked_delegator_reward', { + mixIdentity, + fee: fee?.fee, + }); -export const compoundDelegatorRewards = async (mixIdentity: String) => - invokeWrapper('compound_locked_and_unlocked_delegator_reward', { mixIdentity }); +export const compoundDelegatorRewards = async (mixIdentity: String, fee?: FeeDetails) => + invokeWrapper('compound_locked_and_unlocked_delegator_reward', { + mixIdentity, + fee: fee?.fee, + }); diff --git a/nym-wallet/src/requests/simulate.ts b/nym-wallet/src/requests/simulate.ts index 96ce7e4472..f224035f23 100644 --- a/nym-wallet/src/requests/simulate.ts +++ b/nym-wallet/src/requests/simulate.ts @@ -1,4 +1,4 @@ -import { FeeDetails } from '@nymproject/types'; +import { FeeDetails, MajorCurrencyAmount } from '@nymproject/types'; import { invokeWrapper } from './wrapper'; export const simulateBondGateway = async (args: any) => invokeWrapper('simulate_bond_gateway', args); @@ -11,11 +11,26 @@ export const simulateUnbondMixnode = async (args: any) => invokeWrapper invokeWrapper('simulate_update_mixnode', args); -export const simulateDelegateToMixnode = async (args: any) => +export const simulateDelegateToMixnode = async (args: { identity: string; amount: MajorCurrencyAmount }) => invokeWrapper('simulate_delegate_to_mixnode', args); -export const simulateUndelegateFromMixnode = async (args: any) => - invokeWrapper('simulate_undelegate_from_mixnode,', args); +export const simulateUndelegateFromMixnode = async (identity: string) => + invokeWrapper('simulate_undelegate_from_mixnode', { identity }); + +export const simulateCompoundDelgatorReward = async (identity: string) => + invokeWrapper('simulate_compound_delegator_reward', { mixIdentity: identity }); + +export const simulateClaimDelgatorReward = async (identity: string) => + invokeWrapper('simulate_claim_delegator_reward', { mixIdentity: identity }); + +export const simulateVestingClaimDelgatorReward = async (identity: string) => + invokeWrapper('simulate_vesting_claim_delegator_reward', { mixIdentity: identity }); + +export const simulateVestingCompoundDelgatorReward = async (identity: string) => + invokeWrapper('simulate_vesting_compound_delegator_reward', { mixIdentity: identity }); + +export const simulateVestingUndelegateFromMixnode = async (args: any) => + invokeWrapper('simulate_vesting_undelegate_from_mixnode', args); export const simulateVestingBondGateway = async (args: any) => invokeWrapper('simulate_vesting_bond_gateway', args); @@ -23,6 +38,9 @@ export const simulateVestingBondGateway = async (args: any) => export const simulateVestingUnbondGateway = async (args: any) => invokeWrapper('simulate_vesting_unbond_gateway', args); +export const simulateVestingDelegateToMixnode = async (args: { identity: string }) => + invokeWrapper('simulate_vesting_delegate_to_mixnode', args); + export const simulateVestingBondMixnode = async (args: any) => invokeWrapper('simulate_vesting_bond_mixnode', args); diff --git a/nym-wallet/src/requests/vesting.ts b/nym-wallet/src/requests/vesting.ts index 5e8066c785..55a0dec777 100644 --- a/nym-wallet/src/requests/vesting.ts +++ b/nym-wallet/src/requests/vesting.ts @@ -1,5 +1,6 @@ import { EnumNodeType, + FeeDetails, Gateway, MajorCurrencyAmount, MixNode, @@ -65,10 +66,12 @@ export const vestingUpdateMixnode = async (profitMarginPercent: number) => export const vestingDelegateToMixnode = async ({ identity, amount, + fee, }: { identity: string; amount: MajorCurrencyAmount; -}) => invokeWrapper('vesting_delegate_to_mixnode', { identity, amount }); + fee?: FeeDetails; +}) => invokeWrapper('vesting_delegate_to_mixnode', { identity, amount, fee: fee?.fee }); export const vestingUndelegateFromMixnode = async (identity: string) => invokeWrapper('vesting_undelegate_from_mixnode', { identity }); diff --git a/nym-wallet/src/utils/index.ts b/nym-wallet/src/utils/index.ts index 1d4686558f..82c3f97f0a 100644 --- a/nym-wallet/src/utils/index.ts +++ b/nym-wallet/src/utils/index.ts @@ -2,6 +2,7 @@ import { appWindow } from '@tauri-apps/api/window'; import bs58 from 'bs58'; import { valid } from 'semver'; import { isValidRawCoin, MajorAmountString } from '@nymproject/types'; +import { TPoolOption } from 'src/components'; import { getLockedCoins, getSpendableCoins, userBalance } from '../requests'; import { Console } from './console'; @@ -107,3 +108,16 @@ export const maximizeWindow = async () => { export function removeObjectDuplicates(arr: T[], id: K) { return arr.filter((v, i, a) => a.findIndex((v2) => v2[id] === v[id]) === i); } + +export const checkTokenBalance = async (tokenPool: TPoolOption, amount: string) => { + let hasEnoughFunds = false; + if (tokenPool === 'locked') { + hasEnoughFunds = await checkHasEnoughLockedTokens(amount); + } + + if (tokenPool === 'balance') { + hasEnoughFunds = await checkHasEnoughFunds(amount); + } + + return hasEnoughFunds; +};