diff --git a/i18n/locales/en/common.json b/i18n/locales/en/common.json index 6b901ecba9..2d8c82e49b 100644 --- a/i18n/locales/en/common.json +++ b/i18n/locales/en/common.json @@ -51,6 +51,7 @@ "Apply filters": "Apply filters", "At this moment there is a connection problem with the tweets feed": "At this moment there is a connection problem with the tweets feed", "Auto sign out": "Auto sign out", + "Available balance": "Available balance", "Awaiting slot": "Awaiting slot", "BTC address": "BTC address", "Back": "Back", @@ -427,7 +428,7 @@ "Send": "Send", "Send LSK and BTC": "Send LSK and BTC", "Send a reclaim transaction": "Send a reclaim transaction", - "Send entire balance": "Send entire balance", + "Send maximum amount": "Send maximum amount", "Send using second passphrase right away": "Send using second passphrase right away", "Send {{amount}} {{token}}": "Send {{amount}} {{token}}", "Send {{token}}": "Send {{token}}", @@ -546,6 +547,7 @@ "Update now": "Update now", "Update to your new account": "Update to your new account", "Updates downloaded, application has to be restarted to apply the updates.": "Updates downloaded, application has to be restarted to apply the updates.", + "Use maximum amount": "Use maximum amount", "Use your old passphrase ": "Use your old passphrase ", "Using your recovery phrase this way should be avoided, and if you don’t need to access your funds now, we recommend waiting for full support of hardware wallets in Lisk Desktop 2.2.0.": "Using your recovery phrase this way should be avoided, and if you don’t need to access your funds now, we recommend waiting for full support of hardware wallets in Lisk Desktop 2.2.0.", "Verify address": "Verify address", @@ -594,6 +596,7 @@ "Written by": "Written by", "YYYY": "YYYY", "You are about to send your entire balance": "You are about to send your entire balance", + "You are about to vote almost your entire balance": "You are about to vote almost your entire balance", "You are disconnected": "You are disconnected", "You can also download, print and store safely your passphrase.": "You can also download, print and store safely your passphrase.", "You can learn more": "You can learn more", diff --git a/src/components/screens/editVote/editVotes.test.js b/src/components/screens/editVote/editVotes.test.js index c659558b79..4bb83735c6 100644 --- a/src/components/screens/editVote/editVotes.test.js +++ b/src/components/screens/editVote/editVotes.test.js @@ -3,11 +3,17 @@ import * as votingActions from '@actions'; import { mountWithRouterAndStore } from '@utils/testHelpers'; import EditVote from './index'; +jest.mock('@api/transaction', () => ({ + getTransactionFee: jest.fn().mockImplementation(() => Promise.resolve({ value: '0.046' })), +})); + jest.mock('@actions/voting', () => ({ voteEdited: jest.fn(), })); describe('EditVote', () => { + const genesis = 'lskdxc4ta5j43jp9ro3f8zqbxta9fn6jwzjucw7yt'; + const delegate = 'lskehj8am9afxdz8arztqajy52acnoubkzvmo9cjy'; const propsWithoutSearch = { t: str => str, history: { @@ -22,21 +28,26 @@ describe('EditVote', () => { history: { push: jest.fn(), location: { - search: '?address=987665L&modal=editVote', + search: `?address=${delegate}&modal=editVote`, }, }, }; const noVote = {}; const withVotes = { - '123456L': { confirmed: 1e9, unconfirmed: 1e9 }, - '987665L': { confirmed: 1e9, unconfirmed: 1e9 }, + [genesis]: { confirmed: 1e9, unconfirmed: 1e9 }, + [delegate]: { confirmed: 1e9, unconfirmed: 1e9 }, }; const state = { account: { passphrase: 'test', info: { - LSK: { summary: { address: '123456L', balance: 10004674000 } }, - BTC: { summary: { address: '123456L', balance: 0 } }, + LSK: { summary: { address: genesis, balance: 10004674000 } }, + BTC: { summary: { address: genesis, balance: 0 } }, + }, + }, + settings: { + token: { + active: 'LSK', }, }, }; @@ -63,7 +74,7 @@ describe('EditVote', () => { ); wrapper.find('.remove-vote').at(0).simulate('click'); expect(votingActions.voteEdited).toHaveBeenCalledWith([{ - address: '123456L', + address: genesis, amount: 0, }]); }); @@ -74,7 +85,7 @@ describe('EditVote', () => { ); wrapper.find('.remove-vote').at(0).simulate('click'); expect(votingActions.voteEdited).toHaveBeenCalledWith([{ - address: '987665L', + address: delegate, amount: 0, }]); }); @@ -91,7 +102,7 @@ describe('EditVote', () => { }); wrapper.find('.confirm').at(0).simulate('click'); expect(votingActions.voteEdited).toHaveBeenCalledWith([{ - address: '123456L', + address: genesis, amount: 2e9, }]); }); @@ -107,6 +118,7 @@ describe('EditVote', () => { name: 'vote', }, }); + wrapper.update(); act(() => { jest.advanceTimersByTime(300); }); wrapper.update(); amountField = wrapper.find('input[name="vote"]').at(0); @@ -127,7 +139,7 @@ describe('EditVote', () => { }); wrapper.find('.confirm').at(0).simulate('click'); expect(votingActions.voteEdited).toHaveBeenCalledWith([{ - address: '987665L', + address: delegate, amount: 2e9, }]); }); diff --git a/src/components/screens/editVote/getMaxAmount.js b/src/components/screens/editVote/getMaxAmount.js new file mode 100644 index 0000000000..1417a6ec0a --- /dev/null +++ b/src/components/screens/editVote/getMaxAmount.js @@ -0,0 +1,64 @@ +import { getTransactionFee } from '@api/transaction'; +import { + VOTE_AMOUNT_STEP, + MIN_ACCOUNT_BALANCE, + MODULE_ASSETS_NAME_ID_MAP, +} from '@constants'; +import { toRawLsk } from '@utils/lsk'; +import { normalizeVotesForTx, getNumberOfSignatures } from '@shared/transactionPriority'; + +/** + * Calculates the maximum vote amount possible. It + * Takes the current votes, minimum account balance and + * transaction fee into account. + * + * @param {object} account - Lisk account info from the Redux store + * @param {object} network - Network info from the Redux store + * @param {object} transaction - Raw transaction object + * @param {object} voting - List of votes from the Redux store + * @returns {Number} - Maximum possible vote amount + */ +const getMaxAmount = async (account, network, voting, address) => { + const balance = account.summary?.balance ?? 0; + const totalUnconfirmedVotes = Object.values(voting) + .filter(vote => vote.confirmed < vote.unconfirmed) + .map(vote => vote.unconfirmed - vote.confirmed) + .reduce((total, amount) => (total + amount), 0); + + const maxVoteAmount = Math.floor( + (balance - totalUnconfirmedVotes - MIN_ACCOUNT_BALANCE) / 1e9, + ) * 1e9; + + const transaction = { + fee: 1e6, + votes: normalizeVotesForTx({ + ...voting, + [address]: { + confirmed: voting[address] ? voting[address].confirmed : 0, + unconfirmed: maxVoteAmount, + }, + }), + nonce: account.sequence?.nonce, + senderPublicKey: account.summary?.publicKey, + moduleAssetId: MODULE_ASSETS_NAME_ID_MAP.voteDelegate, + }; + + const maxAmountFee = await getTransactionFee({ + token: 'LSK', + account, + network, + transaction, + selectedPriority: { title: 'Normal', value: 0, selectedIndex: 0 }, // Always set to LOW + numberOfSignatures: getNumberOfSignatures(account), + }, 'LSK'); + + // If the "sum of vote amounts + fee + dust" exceeds balance + // return 10 LSK less, since votes must be multiplications of 10 LSK. + if ((maxVoteAmount + toRawLsk(maxAmountFee.value)) <= ( + balance - totalUnconfirmedVotes - MIN_ACCOUNT_BALANCE)) { + return maxVoteAmount; + } + return maxVoteAmount - VOTE_AMOUNT_STEP; +}; + +export default getMaxAmount; diff --git a/src/components/screens/editVote/getMaxAmount.test.js b/src/components/screens/editVote/getMaxAmount.test.js new file mode 100644 index 0000000000..e3423a4c1b --- /dev/null +++ b/src/components/screens/editVote/getMaxAmount.test.js @@ -0,0 +1,87 @@ +import getMaxAmount from './getMaxAmount'; +import accounts from '../../../../test/constants/accounts'; + +const account = { + ...accounts.genesis, + summary: { + ...accounts.genesis.summary, + balance: 100.106e8, + }, +}; +const network = { + network: { + networks: { + LSK: { + networkIdentifier: '15f0dacc1060e91818224a94286b13aa04279c640bd5d6f193182031d133df7c', + moduleAssets: [ + { + id: '2:0', + name: 'token:transfer', + }, + { + id: '4:0', + name: 'keys:registerMultisignatureGroup', + }, + { + id: '5:0', + name: 'dpos:registerDelegate', + }, + { + id: '5:1', + name: 'dpos:voteDelegate', + }, + { + id: '5:2', + name: 'dpos:unlockToken', + }, + { + id: '5:3', + name: 'dpos:reportDelegateMisbehavior', + }, + { + id: '1000:0', + name: 'legacyAccount:reclaimLSK', + }, + ], + serviceUrl: 'https://testnet-service.lisk.com', + }, + }, + name: 'testnet', + }, +}; +const voting = { + voting: { + [accounts.genesis.summary.address]: { + confirmed: 20e8, + unconfirmed: 20e8, + username: 'genesis', + }, + }, +}; + +jest.mock('@api/transaction', () => ({ + getTransactionFee: jest.fn().mockImplementation(() => Promise.resolve({ value: '0.046' })), +})); + +jest.mock('@actions/voting', () => ({ + voteEdited: jest.fn(), +})); + +describe('getMaxAmount', () => { + it('Returns 10n LSK if: balance >= (10n LSK + fee + dust)', async () => { + const result = await getMaxAmount(account, network, voting, accounts.genesis.summary.address); + expect(result).toBe(1e10); + }); + + it('Returns (n-1) * 10 LSK if: 10n LSK < balance < (10n LSK + fee + dust)', async () => { + const acc = { + ...accounts.genesis, + summary: { + ...accounts.genesis.summary, + balance: 1e10, + }, + }; + const result = await getMaxAmount(acc, network, voting, accounts.genesis.summary.address); + expect(result).toBe(9e9); + }); +}); diff --git a/src/components/screens/editVote/index.js b/src/components/screens/editVote/index.js index 529245458b..5e958fade1 100644 --- a/src/components/screens/editVote/index.js +++ b/src/components/screens/editVote/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { withRouter } from 'react-router-dom'; import { withTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -19,7 +19,7 @@ import LiskAmount from '@shared/liskAmount'; import Converter from '@shared/converter'; import { PrimaryButton, WarningButton } from '@toolbox/buttons'; import useVoteAmountField from './useVoteAmountField'; - +import getMaxAmount from './getMaxAmount'; import styles from './editVote.css'; const getTitles = t => ({ @@ -38,13 +38,18 @@ const AddVote = ({ history, t, }) => { const dispatch = useDispatch(); + const { account, network, voting } = useSelector(state => state); const host = useSelector(state => state.account.info.LSK.summary.address); const address = selectSearchParamValue(history.location.search, 'address'); const existingVote = useSelector(state => state.voting[address || host]); - const activeToken = tokenMap.LSK.key; const balance = useSelector(selectAccountBalance); - const [voteAmount, setVoteAmount] = useVoteAmountField(existingVote ? fromRawLsk(existingVote.unconfirmed) : '', balance); + const [voteAmount, setVoteAmount] = useVoteAmountField(existingVote ? fromRawLsk(existingVote.unconfirmed) : ''); const mode = existingVote ? 'edit' : 'add'; + const [maxAmount, setMaxAmount] = useState(0); + useEffect(() => { + getMaxAmount(account.info.LSK, network, voting, address || host) + .then(setMaxAmount); + }, [account, voting]); const confirm = () => { dispatch(voteEdited([{ @@ -77,10 +82,10 @@ const AddVote = ({ {titles.description} -

Available balance

+

{t('Available balance')}

- + diff --git a/src/components/screens/editVote/useVoteAmountField.js b/src/components/screens/editVote/useVoteAmountField.js index ff7e288d66..c2fa1d87d3 100644 --- a/src/components/screens/editVote/useVoteAmountField.js +++ b/src/components/screens/editVote/useVoteAmountField.js @@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { validateAmountFormat } from '@utils/validators'; +import { selectAccountBalance } from '@store/selectors'; import { tokenMap, regex } from '@constants'; +import { useSelector } from 'react-redux'; let loaderTimeout = null; @@ -24,22 +26,6 @@ const getAmountFeedbackAndError = (value, balance) => { return { error: !!feedback, feedback }; }; -/** - * Calculates the maximum free/available balance to use for voting, - * Accounts for votes in vote queue and voting fee cap - * - * @param {Object} state - The Redux state - * @returns {Number} - Available balance - */ -// const getMaxAmount = (state) => { -// const { balance } = state.account.info.LSK; -// const totalUnconfirmedVotes = Object.values(state.voting) -// .map(vote => Math.max(vote.confirmed, vote.unconfirmed)) -// .reduce((total, amount) => (total + amount), 0); - -// return balance - totalUnconfirmedVotes - 1e8; // only considering fee cap -// }; - /** * Formats and defines potential errors of the vote mount value * Also provides a setter function @@ -48,8 +34,9 @@ const getAmountFeedbackAndError = (value, balance) => { * @param {Number} accountBalance - The account balance value in Beddows * @returns {[Boolean, Function]} The error flag, The setter function */ -const useVoteAmountField = (initialValue, accountBalance) => { +const useVoteAmountField = (initialValue) => { const { i18n } = useTranslation(); + const balance = useSelector(selectAccountBalance); const [amountField, setAmountField] = useState({ value: initialValue, isLoading: false, @@ -68,7 +55,7 @@ const useVoteAmountField = (initialValue, accountBalance) => { } }, [initialValue]); - const onAmountInputChange = ({ value }, balance = accountBalance) => { + const onAmountInputChange = ({ value }) => { const { leadingPoint } = regex.amount[i18n.language]; value = leadingPoint.test(value) ? `0${value}` : value; clearTimeout(loaderTimeout); @@ -78,11 +65,12 @@ const useVoteAmountField = (initialValue, accountBalance) => { value, isLoading: true, }); + const feedback = getAmountFeedbackAndError(value, balance); loaderTimeout = setTimeout(() => { setAmountField({ isLoading: false, value, - ...getAmountFeedbackAndError(value, balance), + ...feedback, }); }, 300); }; diff --git a/src/components/screens/send/form/form.test.js b/src/components/screens/send/form/form.test.js index 38964f0005..db576f9003 100644 --- a/src/components/screens/send/form/form.test.js +++ b/src/components/screens/send/form/form.test.js @@ -271,7 +271,7 @@ describe('Form', () => { const wrapper = mount(
); const { address } = accounts.genesis.summary; wrapper.find('input.recipient').simulate('change', { target: { name: 'recipient', value: address } }); - wrapper.find('.send-entire-balance-button').at(1).simulate('click'); + wrapper.find('.use-entire-balance-button').at(1).simulate('click'); act(() => { jest.advanceTimersByTime(300); }); wrapper.update(); @@ -283,7 +283,7 @@ describe('Form', () => { const wrapper = mount(); const { address } = accounts.genesis.summary; wrapper.find('input.recipient').simulate('change', { target: { name: 'recipient', value: address } }); - wrapper.find('.send-entire-balance-button').at(1).simulate('click'); + wrapper.find('.use-entire-balance-button').at(1).simulate('click'); act(() => { jest.advanceTimersByTime(300); }); wrapper.update(); diff --git a/src/components/screens/send/form/formBase.js b/src/components/screens/send/form/formBase.js index 841a8778fd..18be7d8b91 100644 --- a/src/components/screens/send/form/formBase.js +++ b/src/components/screens/send/form/formBase.js @@ -43,14 +43,13 @@ const FormBase = ({ { children } diff --git a/src/components/screens/send/form/formBtc.test.js b/src/components/screens/send/form/formBtc.test.js index f2ab1f8248..015cd3afda 100644 --- a/src/components/screens/send/form/formBtc.test.js +++ b/src/components/screens/send/form/formBtc.test.js @@ -107,7 +107,7 @@ describe('FormBtc', () => { }); it.skip('should allow to set entire balance', async () => { - wrapper.find('button.send-entire-balance-button').simulate('click'); + wrapper.find('button.use-entire-balance-button').simulate('click'); act(() => { jest.runAllTimers(); }); wrapper.update(); await flushPromises(); diff --git a/src/components/screens/send/form/formLsk.js b/src/components/screens/send/form/formLsk.js index 56f2e3639d..a99e8f164e 100644 --- a/src/components/screens/send/form/formLsk.js +++ b/src/components/screens/send/form/formLsk.js @@ -56,8 +56,6 @@ const FormLsk = (props) => { setCustomFee(value); }; - // console.log('fields', fields); - return ( { let { message: feedback } = validateAmountFormat({ value, token, - funds: token !== tokenMap.LSK.key ? maxAmount : Number(maxAmount) + Number(minAccountBalance), + funds: token !== tokenMap.LSK.key + ? maxAmount : Number(maxAmount) + Number(MIN_ACCOUNT_BALANCE), checklist: token !== tokenMap.LSK.key ? checklist : [...checklist, 'MIN_BALANCE'], }); diff --git a/src/components/screens/votingQueue/editor/editor.js b/src/components/screens/votingQueue/editor/editor.js index b24fcf8467..2373a85a67 100644 --- a/src/components/screens/votingQueue/editor/editor.js +++ b/src/components/screens/votingQueue/editor/editor.js @@ -1,7 +1,11 @@ import React, { useMemo, useState } from 'react'; -import { tokenMap, MODULE_ASSETS_NAME_ID_MAP, minAccountBalance } from '@constants'; +import { tokenMap, MODULE_ASSETS_NAME_ID_MAP, MIN_ACCOUNT_BALANCE } from '@constants'; import { toRawLsk } from '@utils/lsk'; -import TransactionPriority, { useTransactionFeeCalculation, useTransactionPriority } from '@shared/transactionPriority'; +import TransactionPriority, { + useTransactionFeeCalculation, + useTransactionPriority, + normalizeVotesForTx, +} from '@shared/transactionPriority'; import Box from '@toolbox/box'; import BoxContent from '@toolbox/box/content'; import BoxFooter from '@toolbox/box/footer'; @@ -16,23 +20,6 @@ import EmptyState from './emptyState'; import header from './tableHeader'; import styles from './editor.css'; -/** - * Converts the votes object stored in Redux store - * which looks like { delegateAddress: { confirmed, unconfirmed } } - * into an array of objects that Lisk Element expects, looking like - * [{ delegatesAddress, amount }] - * - * @param {Object} votes - votes object retrieved from the Redux store - * @returns {Array} Array of votes as Lisk Element expects - */ -const normalizeVotesForTx = votes => - Object.keys(votes) - .filter(address => votes[address].confirmed !== votes[address].unconfirmed) - .map(delegateAddress => ({ - delegateAddress, - amount: (votes[delegateAddress].unconfirmed - votes[delegateAddress].confirmed).toString(), - })); - /** * Determines the number of votes that have been * added, removed or edited. @@ -102,7 +89,7 @@ const validateVotes = (votes, balance, fee, account, t) => { messages.push(t('You don\'t have enough LSK in your account.')); } - if ((balance - addedVoteAmount) < minAccountBalance && (balance - addedVoteAmount)) { + if ((balance - addedVoteAmount) < MIN_ACCOUNT_BALANCE && (balance - addedVoteAmount)) { messages.push('The vote amounts are too high. You should keep 0.05 LSK available in your account.'); } diff --git a/src/components/screens/votingQueue/editor/editor.test.js b/src/components/screens/votingQueue/editor/editor.test.js index d7fa901c8a..e24337ea1c 100644 --- a/src/components/screens/votingQueue/editor/editor.test.js +++ b/src/components/screens/votingQueue/editor/editor.test.js @@ -1,5 +1,5 @@ import { act } from 'react-dom/test-utils'; -import { moduleAssetSchemas, minAccountBalance } from '@constants'; +import { moduleAssetSchemas, MIN_ACCOUNT_BALANCE } from '@constants'; import { mountWithRouter } from '@utils/testHelpers'; import { getTransactionBaseFees, getTransactionFee } from '@api/transaction'; @@ -145,7 +145,7 @@ describe('VotingQueue.Editor', () => { }); it('Shows an error if trying to vote with amounts leading to insufficient balance', async () => { - props.account.token.balance = `${parseInt(accounts.genesis.token.balance, 10) + (minAccountBalance * 0.8)}`; + props.account.token.balance = `${parseInt(accounts.genesis.token.balance, 10) + (MIN_ACCOUNT_BALANCE * 0.8)}`; const wrapper = mountWithRouter(Editor, { ...props, votes: minimumBalanceVotes }); await flushPromises(); act(() => { wrapper.update(); }); diff --git a/src/components/screens/votingQueue/editor/voteRow.js b/src/components/screens/votingQueue/editor/voteRow.js index c6429f41d6..d9a8730882 100644 --- a/src/components/screens/votingQueue/editor/voteRow.js +++ b/src/components/screens/votingQueue/editor/voteRow.js @@ -1,9 +1,8 @@ import React, { useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { tokenMap } from '@constants'; import { voteEdited } from '@actions'; -import { selectAccountBalance } from '@store/selectors'; import { fromRawLsk, toRawLsk } from '@utils/lsk'; import { truncateAddress } from '@utils/account'; import AccountVisual from '@toolbox/accountVisual'; @@ -25,8 +24,7 @@ const VoteRow = ({ }) => { const [state, setState] = useState(unconfirmed === '' ? ComponentState.editing : ComponentState.notEditing); const dispatch = useDispatch(); - const balance = useSelector(selectAccountBalance); - const [voteAmount, setVoteAmount] = useVoteAmountField(fromRawLsk(unconfirmed), balance); + const [voteAmount, setVoteAmount] = useVoteAmountField(fromRawLsk(unconfirmed)); const truncatedAddress = truncateAddress(address); const handleFormSubmission = (e) => { @@ -88,11 +86,11 @@ const VoteRow = ({ >
{ + const { t } = useTranslation(); + return ( +
+ + {message || t('You are about to send your entire balance')} +
+
+ ); +}; + const AmountField = ({ - amount, maxAmount, setAmountField, className, - title, maxAmountTitle, inputPlaceHolder, name, - displayConverter, t, + amount, maxAmount, onChange, className, + label, useMaxLabel, placeholder, name, + displayConverter, useMaxWarning, }) => { const [showEntireBalanceWarning, setShowEntireBalanceWarning] = useState(false); const setEntireBalance = (e) => { @@ -21,18 +35,18 @@ const AmountField = ({ value: fromRawLsk(maxAmount.value), format: '0.[00000000]', }); - setAmountField({ value }, maxAmount); + onChange({ value }, maxAmount); setShowEntireBalanceWarning(true); }; const resetInput = (e) => { e.preventDefault(); setShowEntireBalanceWarning(false); - setAmountField({ value: '' }, maxAmount); + onChange({ value: '' }, maxAmount); }; const handleAmountChange = ({ target }) => { - setAmountField(target, maxAmount); + onChange(target, maxAmount); if (showEntireBalanceWarning && target.value < maxAmount.value) { setShowEntireBalanceWarning(false); } @@ -43,20 +57,17 @@ const AmountField = ({ }; return ( -