diff --git a/setup/react/assets/images/icons/reset.svg b/setup/react/assets/images/icons/reset.svg new file mode 100644 index 0000000000..308fd62fd9 --- /dev/null +++ b/setup/react/assets/images/icons/reset.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/locales/en/common.json b/src/locales/en/common.json index c970f13c0e..b9d39a42dc 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -458,6 +458,7 @@ "Please click on “Switch to signing account” ": "Please click on “Switch to signing account” ", "Please confirm the transaction on your {{deviceModel}}": "Please confirm the transaction on your {{deviceModel}}", "Please drop the JSON here.": "Please drop the JSON here.", + "Please ensure to send the previously initiated transactions. Check the transaction sequence, and broadcast them only after they have been fully signed, in their original order.": "Please ensure to send the previously initiated transactions. Check the transaction sequence, and broadcast them only after they have been fully signed, in their original order.", "Please enter a valid blockchain app URI.": "Please enter a valid blockchain app URI.", "Please enter a valid bls key value": "Please enter a valid bls key value", "Please enter a valid generator key value": "Please enter a valid generator key value", diff --git a/src/modules/account/hooks/useAccounts.js b/src/modules/account/hooks/useAccounts.js index e35d549d90..82854f6e2f 100644 --- a/src/modules/account/hooks/useAccounts.js +++ b/src/modules/account/hooks/useAccounts.js @@ -1,14 +1,15 @@ import { useMemo, useCallback } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { selectAccounts } from '@account/store/selectors'; +import { selectAccounts, selectAccountNonce } from '@account/store/selectors'; import { selectHWAccounts } from '@hardwareWallet/store/selectors/hwSelectors'; -import { addAccount, deleteAccount } from '../store/action'; +import { addAccount, deleteAccount, resetAccountNonce, setAccountNonce } from '../store/action'; // eslint-disable-next-line export function useAccounts() { const dispatch = useDispatch(); const accountsObject = useSelector(selectAccounts); const hwAccounts = useSelector(selectHWAccounts); + const nonceMap = useSelector(selectAccountNonce); const setAccount = useCallback((account) => dispatch(addAccount(account)), []); const deleteAccountByAddress = useCallback((address) => dispatch(deleteAccount(address)), []); @@ -22,11 +23,27 @@ export function useAccounts() { const getAccountByPublicKey = (pubkey) => accounts.find((account) => account.metadata.pubkey === pubkey); + const setNonceByAccount = (address, nonce, transactionHex) => + dispatch(setAccountNonce(address, nonce, transactionHex)); + + const getNonceByAccount = (address) => { + const accountNonceMap = nonceMap[address] ?? {}; + const accountNonceValues = Object.values(accountNonceMap); + const nonceList = accountNonceValues.length ? accountNonceValues : [0]; + return Math.max(...nonceList); + }; + + const resetNonceByAccount = (address, onChainNonce) => + dispatch(resetAccountNonce(address, onChainNonce)); + return { accounts, setAccount, deleteAccountByAddress, getAccountByPublicKey, getAccountByAddress, + setNonceByAccount, + getNonceByAccount, + resetNonceByAccount, }; } diff --git a/src/modules/account/hooks/useAccounts.test.js b/src/modules/account/hooks/useAccounts.test.js index 1daaa99c20..4b62513277 100644 --- a/src/modules/account/hooks/useAccounts.test.js +++ b/src/modules/account/hooks/useAccounts.test.js @@ -3,11 +3,18 @@ import mockSavedAccounts from '@tests/fixtures/accounts'; import actionTypes from '@account/store/actionTypes'; import { useAccounts } from './useAccounts'; +const txHex = 'a24f94966cf213deb90854c41cf1f27906135b7001a49e53a9722ebf5fc67481'; +const accountNonce = 2; const mockDispatch = jest.fn(); const accountStateObject = { [mockSavedAccounts[0].metadata.address]: mockSavedAccounts[0] }; const mockState = { account: { list: accountStateObject, + localNonce: { + [mockSavedAccounts[0].metadata.address]: { + [txHex]: accountNonce, + }, + }, }, }; jest.mock('react-redux', () => ({ @@ -70,4 +77,31 @@ describe('useAccount hook', () => { expect(mockDispatch).toHaveBeenCalledTimes(1); expect(mockDispatch).toHaveBeenCalledWith(expectedAction); }); + + it('setNonceByAccount should dispatch an action', async () => { + const { setNonceByAccount } = result.current; + const expectedAction = { + type: actionTypes.setAccountNonce, + address: mockSavedAccounts[0].metadata.address, + nonce: 2, + transactionHex: txHex, + }; + act(() => { + setNonceByAccount(mockSavedAccounts[0].metadata.address, 2, txHex); + }); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith(expectedAction); + }); + + it('getNonceByAccount should retrieve stored nonce', async () => { + const { getNonceByAccount } = result.current; + const storedNonce = getNonceByAccount(mockSavedAccounts[0].metadata.address); + expect(storedNonce).toEqual(accountNonce); + }); + + it('getNonceByAccount should retrieve 0 if no stored nonce', async () => { + const { getNonceByAccount } = result.current; + const storedNonce = getNonceByAccount(mockSavedAccounts[1].metadata.address); + expect(storedNonce).toEqual(0); + }); }); diff --git a/src/modules/account/store/action.js b/src/modules/account/store/action.js index 7d6c1241ef..3bb62c40ec 100644 --- a/src/modules/account/store/action.js +++ b/src/modules/account/store/action.js @@ -21,6 +21,19 @@ export const addAccount = (encryptedAccount) => ({ encryptedAccount, }); +export const setAccountNonce = (address, nonce, transactionHex) => ({ + type: actionTypes.setAccountNonce, + address, + nonce, + transactionHex, +}); + +export const resetAccountNonce = (address, onChainNonce) => ({ + type: actionTypes.resetAccountNonce, + address, + nonce: onChainNonce, +}); + export const updateAccount = ({ encryptedAccount, accountDetail }) => ({ type: actionTypes.updateAccount, encryptedAccount, diff --git a/src/modules/account/store/actionTypes.js b/src/modules/account/store/actionTypes.js index cffef02dc4..aedc146a00 100644 --- a/src/modules/account/store/actionTypes.js +++ b/src/modules/account/store/actionTypes.js @@ -2,6 +2,8 @@ const actionTypes = { setCurrentAccount: 'SET_CURRENT_ACCOUNT', updateCurrentAccount: 'UPDATE_CURRENT_ACCOUNT', addAccount: 'ADD_ACCOUNT', + setAccountNonce: 'SET_ACCOUNT_NONCE', + resetAccountNonce: 'RESET_ACCOUNT_NONCE', updateAccount: 'UPDATE_ACCOUNT', deleteAccount: 'DELETE_ACCOUNT', }; diff --git a/src/modules/account/store/reducer.js b/src/modules/account/store/reducer.js index 0c4b9d8a24..1758dc1467 100644 --- a/src/modules/account/store/reducer.js +++ b/src/modules/account/store/reducer.js @@ -58,14 +58,39 @@ export const list = (state = {}, { type, encryptedAccount, accountDetail, addres } }; +export const localNonce = (state = {}, { type, address, nonce, transactionHex }) => { + switch (type) { + case actionTypes.setAccountNonce: + if (state[address]?.[transactionHex]) { + return state; + } + return { + ...state, + [address]: { + ...state[address], + [transactionHex]: nonce, + }, + }; + + case actionTypes.resetAccountNonce: + return { + ...state, + [address]: { defaultNonce: nonce }, + }; + + default: + return state; + } +}; + const persistConfig = { key: 'account', storage, - whitelist: ['list', 'current'], // only navigation will be persisted + whitelist: ['list', 'current', 'localNonce'], // only navigation will be persisted blacklist: [], }; -const accountReducer = combineReducers({ current, list }); +const accountReducer = combineReducers({ current, list, localNonce }); // eslint-disable-next-line import/prefer-default-export export const account = persistReducer(persistConfig, accountReducer); diff --git a/src/modules/account/store/reducer.test.js b/src/modules/account/store/reducer.test.js index 0576455d78..83dceaceca 100644 --- a/src/modules/account/store/reducer.test.js +++ b/src/modules/account/store/reducer.test.js @@ -1,6 +1,6 @@ import mockSavedAccounts from '@tests/fixtures/accounts'; import actionTypes from './actionTypes'; -import { list, current } from './reducer'; +import { list, current, localNonce } from './reducer'; describe('Auth reducer', () => { it('Should return encryptedAccount if setCurrentAccount action type is triggered', async () => { @@ -82,4 +82,41 @@ describe('Auth reducer', () => { expect(list(defaultState, actionData)).toEqual({}); expect(current(mockSavedAccounts[0], actionData)).toEqual(mockSavedAccounts[0]); }); + + it('Should set account nonce with required details', async () => { + const txHex = 'a24f94966cf213deb90854c41cf1f27906135b7001a49e53a9722ebf5fc67481'; + const actionData = { + type: actionTypes.setAccountNonce, + address: mockSavedAccounts[1].metadata.address, + nonce: 1, + transactionHex: txHex, + }; + const expectedState = { + [mockSavedAccounts[1].metadata.address]: { + [txHex]: 1, + }, + }; + expect(localNonce({}, actionData)).toEqual(expectedState); + }); + + it('Should return existing or default account nonce if no transaction hex is passed', async () => { + const txHex = 'a24f94966cf213deb90854c41cf1f27906135b7001a49e53a9722ebf5fc67481'; + const actionData = { + type: actionTypes.setAccountNonce, + address: mockSavedAccounts[1].metadata.address, + nonce: 1, + transactionHex: txHex, + }; + const expectedState = { + [mockSavedAccounts[1].metadata.address]: { + [txHex]: 1, + }, + }; + const existingState = { + [mockSavedAccounts[1].metadata.address]: { + [txHex]: 1, + }, + }; + expect(localNonce(existingState, actionData)).toEqual(expectedState); + }); }); diff --git a/src/modules/account/store/selectors.js b/src/modules/account/store/selectors.js index 04ace7e353..c59ae294bd 100644 --- a/src/modules/account/store/selectors.js +++ b/src/modules/account/store/selectors.js @@ -1,2 +1,3 @@ export const selectCurrentAccount = (state) => state.account.current; export const selectAccounts = (state) => state.account.list || []; +export const selectAccountNonce = (state) => state.account.localNonce || {}; diff --git a/src/modules/auth/hooks/useNonceSync.js b/src/modules/auth/hooks/useNonceSync.js new file mode 100644 index 0000000000..2f661b45e5 --- /dev/null +++ b/src/modules/auth/hooks/useNonceSync.js @@ -0,0 +1,59 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCurrentApplication } from '@blockchainApplication/manage/hooks'; +import { useCurrentAccount, useAccounts } from '@account/hooks'; +import useSettings from '@settings/hooks/useSettings'; +import { AUTH } from 'src/const/queries'; +import { useAuthConfig } from './queries'; + +// eslint-disable-next-line max-statements +const useNonceSync = () => { + const queryClient = useQueryClient(); + const [currentApplication] = useCurrentApplication(); + const [currentAccount] = useCurrentAccount(); + const { setNonceByAccount, getNonceByAccount, resetNonceByAccount } = useAccounts(); + const currentAccountAddress = currentAccount.metadata.address; + const { mainChainNetwork } = useSettings('mainChainNetwork'); + const chainID = currentApplication.chainID; + const customConfig = { + params: { + address: currentAccountAddress, + }, + }; + const serviceUrl = mainChainNetwork?.serviceUrl; + const config = useAuthConfig(customConfig); + const authData = queryClient.getQueryData([AUTH, chainID, config, serviceUrl]); + const onChainNonce = authData?.data?.nonce ? BigInt(authData?.data.nonce) : BigInt('0'); + const authNonce = typeof onChainNonce === 'bigint' ? onChainNonce.toString() : onChainNonce; + + const [accountNonce, setAccountNonce] = useState(onChainNonce.toString()); + const currentAccountNonce = getNonceByAccount(currentAccountAddress); + + // Store nonce by address in accounts store + const handleLocalNonce = (currentNonce) => { + const storedNonce = BigInt(currentAccountNonce || 0); + const localNonce = storedNonce < currentNonce ? currentNonce : storedNonce; + const localNonceStr = localNonce.toString(); + setNonceByAccount(currentAccountAddress, localNonceStr, 'defaultNonce'); + + setAccountNonce(localNonceStr); + }; + + useEffect(() => { + handleLocalNonce(onChainNonce); + }, [onChainNonce]); + + // Increment nonce after transaction signing + const incrementNonce = useCallback((transactionHex) => { + const localNonce = BigInt(Math.max(currentAccountNonce, Number(accountNonce))) + BigInt(1); + setNonceByAccount(currentAccountAddress, localNonce.toString(), transactionHex); + }, []); + + const resetNonce = () => { + resetNonceByAccount(currentAccountAddress, authNonce); + }; + + return { accountNonce, onChainNonce: authNonce, incrementNonce, resetNonce }; +}; + +export default useNonceSync; diff --git a/src/modules/auth/hooks/useNonceSync.test.js b/src/modules/auth/hooks/useNonceSync.test.js new file mode 100644 index 0000000000..2e50cf345f --- /dev/null +++ b/src/modules/auth/hooks/useNonceSync.test.js @@ -0,0 +1,58 @@ +import { renderHook } from '@testing-library/react-hooks'; +import * as ReactQuery from '@tanstack/react-query'; +import { queryWrapper as wrapper } from 'src/utils/test/queryWrapper'; +import mockSavedAccounts from '@tests/fixtures/accounts'; +import { useAccounts } from 'src/modules/account/hooks'; +import { mockAuth } from '@auth/__fixtures__'; +import useNonceSync from './useNonceSync'; + +const mockedCurrentAccount = mockSavedAccounts[0]; +const mockSetNonceByAccount = jest.fn(); +const mockModifiedMockAuth = { ...mockAuth, data: { ...mockAuth.data, nonce: '2' } }; + +jest.mock('@account/hooks/useCurrentAccount', () => ({ + useCurrentAccount: jest.fn(() => [mockedCurrentAccount, jest.fn()]), +})); +jest.mock('@account/hooks/useAccounts'); +jest.mock('@auth/hooks/queries/useAuth'); + +describe('useNonceSync', () => { + it('renders properly', async () => { + jest + .spyOn(ReactQuery, 'useQueryClient') + .mockReturnValue({ getQueryData: () => mockModifiedMockAuth }); + useAccounts.mockReturnValue({ + accounts: mockedCurrentAccount, + setNonceByAccount: mockSetNonceByAccount, + getNonceByAccount: jest.fn().mockReturnValue(3), + }); + const { result } = renderHook(() => useNonceSync(), { wrapper }); + expect(result.current.accountNonce).toEqual('3'); + result.current.incrementNonce(); + expect(mockSetNonceByAccount).toHaveBeenCalled(); + }); + + it('renders properly if auth nonce is undefined and no local nonce has been previously stored', () => { + jest.spyOn(ReactQuery, 'useQueryClient').mockReturnValue({ getQueryData: () => {} }); + useAccounts.mockReturnValue({ + accounts: mockedCurrentAccount, + setNonceByAccount: mockSetNonceByAccount, + getNonceByAccount: jest.fn().mockReturnValue(undefined), + }); + const { result } = renderHook(() => useNonceSync(), { wrapper }); + expect(result.current.accountNonce).toEqual('0'); + }); + + it("updates local nonce if it's less than on-chain nonce", () => { + jest + .spyOn(ReactQuery, 'useQueryClient') + .mockReturnValue({ getQueryData: () => mockModifiedMockAuth }); + useAccounts.mockReturnValue({ + accounts: mockedCurrentAccount, + setNonceByAccount: mockSetNonceByAccount, + getNonceByAccount: jest.fn().mockReturnValue(1), + }); + const { result } = renderHook(() => useNonceSync(), { wrapper }); + expect(result.current.accountNonce).toEqual('2'); + }); +}); diff --git a/src/modules/blockchainApplication/connection/components/RequestSignMessageDialog/index.test.js b/src/modules/blockchainApplication/connection/components/RequestSignMessageDialog/index.test.js index e65ee93cd0..2d218ed95e 100644 --- a/src/modules/blockchainApplication/connection/components/RequestSignMessageDialog/index.test.js +++ b/src/modules/blockchainApplication/connection/components/RequestSignMessageDialog/index.test.js @@ -48,6 +48,8 @@ useCurrentAccount.mockReturnValue([mockCurrentAccount, mockSetCurrentAccount]); useAccounts.mockReturnValue({ getAccountByAddress: () => mockSavedAccounts[0], accounts: mockSavedAccounts, + setNonceByAccount: jest.fn(), + getNonceByAccount: () => 1, }); useBlockchainApplicationMeta.mockReturnValue({ diff --git a/src/modules/legacy/components/Summary/summary.test.js b/src/modules/legacy/components/Summary/summary.test.js index 60e93f272d..a56dd1e14e 100644 --- a/src/modules/legacy/components/Summary/summary.test.js +++ b/src/modules/legacy/components/Summary/summary.test.js @@ -20,6 +20,7 @@ const mockedCurrentAccount = mockSavedAccounts[0]; jest.mock('@auth/hooks/queries/useAuth'); jest.mock('@account/hooks', () => ({ + ...jest.requireActual('@account/hooks'), useCurrentAccount: jest.fn(() => [mockedCurrentAccount, jest.fn()]), })); diff --git a/src/modules/pos/validator/components/ClaimRewardsSummary/ClaimRewardsSummary.test.js b/src/modules/pos/validator/components/ClaimRewardsSummary/ClaimRewardsSummary.test.js index a8658cce4b..47cd087650 100644 --- a/src/modules/pos/validator/components/ClaimRewardsSummary/ClaimRewardsSummary.test.js +++ b/src/modules/pos/validator/components/ClaimRewardsSummary/ClaimRewardsSummary.test.js @@ -1,5 +1,5 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { screen, fireEvent } from '@testing-library/react'; +import { smartRender } from 'src/utils/testHelpers'; import mockSavedAccounts from '@tests/fixtures/accounts'; import { useRewardsClaimable } from '@pos/reward/hooks/queries'; import wallets from '@tests/constants/wallets'; @@ -8,10 +8,10 @@ import { useAuth } from '@auth/hooks/queries'; import { mockAuth } from '@auth/__fixtures__'; import { mockAppsTokens } from '@token/fungible/__fixtures__'; import usePosToken from '@pos/validator/hooks/usePosToken'; -import { mount } from 'enzyme'; import ClaimRewardsSummary from './index'; jest.mock('@account/hooks', () => ({ + ...jest.requireActual('@account/hooks'), useCurrentAccount: jest.fn(() => [mockSavedAccounts[0], jest.fn()]), })); jest.mock('@auth/hooks/queries'); @@ -56,24 +56,25 @@ describe('ClaimRewardsSummary', () => { useAuth.mockReturnValue({ data: mockAuth }); usePosToken.mockReturnValue({ token: mockAppsTokens.data[0] }); useRewardsClaimable.mockReturnValue({ data: mockRewardsClaimableWithToken }); + const config = { queryClient: true /* renderType: 'mount' */ }; it('should display properly', async () => { - render(); + smartRender(ClaimRewardsSummary, props, config); expect(screen.getByText('Confirm')).toBeTruthy(); }); it('should go to prev page when click Go back button', () => { - const wrapper = mount(); + smartRender(ClaimRewardsSummary, props, config); expect(props.prevStep).not.toBeCalled(); - wrapper.find('button.cancel-button').simulate('click'); + fireEvent.click(screen.getByAltText('arrowLeftTailed')); expect(props.prevStep).toBeCalled(); }); it('should submit transaction and action function when click in confirm button', () => { - const wrapper = mount(); + smartRender(ClaimRewardsSummary, props, config); expect(props.nextStep).not.toBeCalled(); - wrapper.find('button.confirm-button').simulate('click'); + fireEvent.click(screen.getByText('Confirm')); expect(props.nextStep).toBeCalledWith({ actionFunction: props.claimedRewards, formProps: props.formProps, diff --git a/src/modules/pos/validator/components/RegisterValidatorSummary/RegisterValidatorSummary.test.js b/src/modules/pos/validator/components/RegisterValidatorSummary/RegisterValidatorSummary.test.js index f62d85dd88..21812cab63 100644 --- a/src/modules/pos/validator/components/RegisterValidatorSummary/RegisterValidatorSummary.test.js +++ b/src/modules/pos/validator/components/RegisterValidatorSummary/RegisterValidatorSummary.test.js @@ -1,6 +1,4 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { mountWithRouterAndStore } from 'src/utils/testHelpers'; +import { smartRender } from 'src/utils/testHelpers'; import accounts from '@tests/constants/wallets'; import { useAuth } from '@auth/hooks/queries'; import mockSavedAccounts from '@tests/fixtures/accounts'; @@ -10,8 +8,10 @@ import Summary from './RegisterValidatorSummary'; const mockedCurrentAccount = mockSavedAccounts[0]; jest.mock('@auth/hooks/queries'); jest.mock('@account/hooks', () => ({ + ...jest.requireActual('@account/hooks'), useCurrentAccount: jest.fn(() => [mockedCurrentAccount, jest.fn()]), })); +const config = { renderType: 'mount', queryClient: true }; describe('Validator Registration Summary', () => { const props = { @@ -58,7 +58,7 @@ describe('Validator Registration Summary', () => { useAuth.mockReturnValue({ data: mockAuth }); it('renders properly Summary component', () => { - const wrapper = mount(); + const wrapper = smartRender(Summary, props, config).wrapper; expect(wrapper).toContainMatchingElement('.username-label'); expect(wrapper).toContainMatchingElement('.username'); expect(wrapper).toContainMatchingElement('.address'); @@ -67,14 +67,14 @@ describe('Validator Registration Summary', () => { }); it('go to prev page when click Go back button', () => { - const wrapper = mount(); + const wrapper = smartRender(Summary, props, config).wrapper; expect(props.prevStep).not.toBeCalled(); wrapper.find('button.cancel-button').simulate('click'); expect(props.prevStep).toBeCalled(); }); it('submit user data when click in confirm button', () => { - const wrapper = mountWithRouterAndStore(Summary, props, {}, {}); + const wrapper = smartRender(Summary, props, config).wrapper; expect(props.nextStep).not.toBeCalled(); wrapper.find('button.confirm-button').simulate('click'); expect(props.nextStep).toBeCalledWith({ diff --git a/src/modules/pos/validator/components/StakeSummary/StakeSummary.test.js b/src/modules/pos/validator/components/StakeSummary/StakeSummary.test.js index 5b97398c8b..fff01c6220 100644 --- a/src/modules/pos/validator/components/StakeSummary/StakeSummary.test.js +++ b/src/modules/pos/validator/components/StakeSummary/StakeSummary.test.js @@ -1,4 +1,4 @@ -import { mountWithRouter } from 'src/utils/testHelpers'; +import { mountWithRouterAndQueryClient } from 'src/utils/testHelpers'; import { useAuth } from '@auth/hooks/queries'; import { mockAuth } from 'src/modules/auth/__fixtures__'; import accounts from '@tests/constants/wallets'; @@ -11,6 +11,7 @@ const mockedCurrentAccount = mockSavedAccounts[0]; jest.mock('@auth/hooks/queries'); jest.mock('@account/hooks', () => ({ + ...jest.requireActual('@account/hooks'), useCurrentAccount: jest.fn(() => [mockedCurrentAccount, jest.fn()]), })); jest.mock('@pos/validator/hooks/usePosToken'); @@ -151,13 +152,13 @@ describe('StakingQueue.Summary', () => { usePosToken.mockReturnValue({ token: mockAppsTokens.data[0] }); it('renders properly', () => { - const wrapper = mountWithRouter(Summary, props); + const wrapper = mountWithRouterAndQueryClient(Summary, props); expect(wrapper).toContainMatchingElement('StakeStats'); }); it('renders properly when only new stakes are present', () => { - const wrapper = mountWithRouter(Summary, { + const wrapper = mountWithRouterAndQueryClient(Summary, { ...props, added, }); @@ -166,7 +167,7 @@ describe('StakingQueue.Summary', () => { }); it('renders properly when only removed stakes are present', () => { - const wrapper = mountWithRouter(Summary, { + const wrapper = mountWithRouterAndQueryClient(Summary, { ...props, removed, }); @@ -175,7 +176,7 @@ describe('StakingQueue.Summary', () => { }); it('renders properly when only edited stakes are present', () => { - const wrapper = mountWithRouter(Summary, { + const wrapper = mountWithRouterAndQueryClient(Summary, { ...props, edited, }); @@ -184,7 +185,7 @@ describe('StakingQueue.Summary', () => { }); it('renders properly when a mixture of stakes are present', () => { - const wrapper = mountWithRouter(Summary, { + const wrapper = mountWithRouterAndQueryClient(Summary, { ...props, edited, removed, @@ -195,7 +196,7 @@ describe('StakingQueue.Summary', () => { }); it('should render rewards', () => { - const wrapper = mountWithRouter(Summary, { ...props, edited, removed, added }); + const wrapper = mountWithRouterAndQueryClient(Summary, { ...props, edited, removed, added }); const addedItemList = wrapper.find('[data-testid="stake-item"]').at(0); const editedItemList = wrapper.find('[data-testid="stake-item"]').at(4); const removedItemList = wrapper.find('[data-testid="stake-item"]').at(9); @@ -205,7 +206,7 @@ describe('StakingQueue.Summary', () => { }); it('calls props.nextStep with properties when confirm button is clicked', () => { - const wrapper = mountWithRouter(Summary, props); + const wrapper = mountWithRouterAndQueryClient(Summary, props); wrapper.find('button.confirm-button').simulate('click'); expect(props.nextStep).toHaveBeenCalledWith({ @@ -221,7 +222,7 @@ describe('StakingQueue.Summary', () => { }); it('calls props.nextStep when transaction is confirmed', () => { - const wrapper = mountWithRouter(Summary, { + const wrapper = mountWithRouterAndQueryClient(Summary, { ...props, added, removed, diff --git a/src/modules/pos/validator/components/UnlockBalanceSummary/unlockBalanceSummary.test.js b/src/modules/pos/validator/components/UnlockBalanceSummary/unlockBalanceSummary.test.js index f803d873ad..b8bbccfa82 100644 --- a/src/modules/pos/validator/components/UnlockBalanceSummary/unlockBalanceSummary.test.js +++ b/src/modules/pos/validator/components/UnlockBalanceSummary/unlockBalanceSummary.test.js @@ -1,5 +1,4 @@ -import React from 'react'; -import { mount } from 'enzyme'; +import { smartRender } from 'src/utils/testHelpers'; import wallets from '@tests/constants/wallets'; import mockSavedAccounts from '@tests/fixtures/accounts'; import { useAuth } from '@auth/hooks/queries'; @@ -9,7 +8,12 @@ import { mockAuth } from 'src/modules/auth/__fixtures__'; import Summary from './UnlockBalanceSummary'; const mockedCurrentAccount = mockSavedAccounts[0]; +const config = { + queryClient: true, + renderType: 'mount', +}; jest.mock('@account/hooks', () => ({ + ...jest.requireActual('@account/hooks'), useCurrentAccount: jest.fn(() => [mockedCurrentAccount, jest.fn()]), })); jest.mock('@auth/hooks/queries'); @@ -59,7 +63,7 @@ describe('Locked balance Summary', () => { usePosToken.mockReturnValue({ token: mockAppsTokens.data[0] }); it('renders properly Summary component', () => { - const wrapper = mount(); + const wrapper = smartRender(Summary, props, config).wrapper; expect(wrapper).toContainMatchingElement('.address-label'); expect(wrapper).toContainMatchingElement('.amount-label'); expect(wrapper).toContainMatchingElement('button.confirm-button'); @@ -67,14 +71,14 @@ describe('Locked balance Summary', () => { }); it('go to prev page when click Go back button', () => { - const wrapper = mount(); + const wrapper = smartRender(Summary, props, config).wrapper; expect(props.prevStep).not.toBeCalled(); wrapper.find('button.cancel-button').simulate('click'); expect(props.prevStep).toBeCalled(); }); it('submit transaction and action function when click in confirm button', () => { - const wrapper = mount(); + const wrapper = smartRender(Summary, props, config).wrapper; expect(props.nextStep).not.toBeCalled(); wrapper.find('button.confirm-button').simulate('click'); expect(props.nextStep).toBeCalledWith({ diff --git a/src/modules/token/fungible/components/SendSummary/Summary.js b/src/modules/token/fungible/components/SendSummary/Summary.js index 5d82243b62..02698ab0d5 100644 --- a/src/modules/token/fungible/components/SendSummary/Summary.js +++ b/src/modules/token/fungible/components/SendSummary/Summary.js @@ -14,10 +14,11 @@ const Summary = ({ }) => { const onConfirmAction = { label: t('Send'), - onClick: () => { + onClick: (modifiedTransactionJSON) => { + /* istanbul ignore next */ nextStep({ formProps, - transactionJSON, + transactionJSON: modifiedTransactionJSON || transactionJSON, selectedPriority, actionFunction: tokensTransferred, }); diff --git a/src/modules/token/fungible/components/SendSummary/summary.test.js b/src/modules/token/fungible/components/SendSummary/summary.test.js index c7817bb5c8..c839cbead1 100644 --- a/src/modules/token/fungible/components/SendSummary/summary.test.js +++ b/src/modules/token/fungible/components/SendSummary/summary.test.js @@ -1,4 +1,4 @@ -import { mountWithRouter } from 'src/utils/testHelpers'; +import { smartRender } from 'src/utils/testHelpers'; import { tokenMap } from '@token/fungible/consts/tokens'; import mockBlockchainApplications from '@tests/fixtures/blockchainApplicationsManage'; import { mockAppTokens } from '@tests/fixtures/token'; @@ -15,8 +15,10 @@ import { mockAppsTokens } from '../../__fixtures__'; const mockedCurrentAccount = mockSavedAccounts[0]; jest.mock('@auth/hooks/queries'); jest.mock('@account/hooks', () => ({ + ...jest.requireActual('@account/hooks'), useCurrentAccount: jest.fn(() => [mockedCurrentAccount, jest.fn()]), })); +const config = { queryClient: true, renderType: 'mount' }; describe('Summary', () => { let wrapper; @@ -72,7 +74,7 @@ describe('Summary', () => { }, }, }; - wrapper = mountWithRouter(Summary, { ...props }); + wrapper = smartRender(Summary, props, config).wrapper; }); useAuth.mockReturnValue({ data: mockAuth }); @@ -98,21 +100,25 @@ describe('Summary', () => { }); it('should show props.fields.recipient.title if it is present', () => { - wrapper = mountWithRouter(Summary, { - ...{ - ...props, - formProps: { - ...props.formProps, - params: { - ...props.formProps.params, - recipient: { - ...props.formProps.params.recipient, - title, + wrapper = smartRender( + Summary, + { + ...{ + ...props, + formProps: { + ...props.formProps, + params: { + ...props.formProps.params, + recipient: { + ...props.formProps.params.recipient, + title, + }, }, }, }, }, - }); + config + ).wrapper; expect(wrapper.find('.recipient-address')).toIncludeText( props.transactionJSON.params.recipientAddress ); diff --git a/src/modules/transaction/components/TransactionInfo/TransactionInfo.css b/src/modules/transaction/components/TransactionInfo/TransactionInfo.css index 13c7f434f6..8eb2893cdf 100644 --- a/src/modules/transaction/components/TransactionInfo/TransactionInfo.css +++ b/src/modules/transaction/components/TransactionInfo/TransactionInfo.css @@ -194,3 +194,13 @@ margin-left: 10px; } } + +.warning { + vertical-align: bottom; + margin-right: 5px; +} + +.reset { + vertical-align: text-bottom; + cursor: pointer; +} diff --git a/src/modules/transaction/components/TransactionInfo/TransactionInfo.js b/src/modules/transaction/components/TransactionInfo/TransactionInfo.js index 86954a39c3..97c489f238 100644 --- a/src/modules/transaction/components/TransactionInfo/TransactionInfo.js +++ b/src/modules/transaction/components/TransactionInfo/TransactionInfo.js @@ -3,6 +3,7 @@ import { withTranslation } from 'react-i18next'; import WalletVisual from '@wallet/components/walletVisual'; import TokenAmount from '@token/fungible/components/tokenAmount'; import { extractAddressFromPublicKey } from '@wallet/utils/account'; +import Icon from '@theme/Icon'; import styles from './TransactionInfo.css'; import CustomTransactionInfo from './CustomTransactionInfo'; import { joinModuleAndCommand } from '../../utils'; @@ -37,6 +38,9 @@ const TransactionInfo = ({ date, token, summaryInfo, + nonceWarning, + canResetNonce, + resetTxNonce, }) => { const isRegisterMultisignature = joinModuleAndCommand(transactionJSON) === MODULE_COMMANDS_NAME_MAP.registerMultisignature; @@ -71,7 +75,13 @@ const TransactionInfo = ({
- +
diff --git a/src/modules/transaction/components/TxComposer/index.js b/src/modules/transaction/components/TxComposer/index.js index 6599399ed5..d3d6ba9950 100644 --- a/src/modules/transaction/components/TxComposer/index.js +++ b/src/modules/transaction/components/TxComposer/index.js @@ -4,10 +4,10 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import useTransactionPriority from '@transaction/hooks/useTransactionPriority'; import { useSchemas } from '@transaction/hooks/queries/useSchemas'; -import { useAuth } from '@auth/hooks/queries'; +import useNonceSync from '@auth/hooks/useNonceSync'; import { useCurrentAccount } from '@account/hooks'; -import Box from 'src/theme/box'; -import BoxFooter from 'src/theme/box/footer'; +import Box from '@theme/box'; +import BoxFooter from '@theme/box/footer'; import TransactionPriority from '@transaction/components/TransactionPriority'; import { fromTransactionJSON, @@ -20,8 +20,8 @@ import { convertFromBaseDenom, convertToBaseDenom } from '@token/fungible/utils/ import { useDeprecatedAccount } from '@account/hooks/useDeprecatedAccount'; import { useTransactionFee } from '@transaction/hooks/useTransactionFee'; import { useTokenBalances } from '@token/fungible/hooks/queries'; -import { PrimaryButton } from 'src/theme/buttons'; -import { useCommandSchema } from 'src/modules/network/hooks'; +import { PrimaryButton } from '@theme/buttons'; +import { useCommandSchema } from '@network/hooks'; import Feedback from './Feedback'; import { getFeeStatus } from '../../utils/helpers'; import { MODULE_COMMANDS_NAME_MAP } from '../../configuration/moduleCommand'; @@ -49,7 +49,7 @@ const TxComposer = ({ metadata: { pubkey, address }, }, ] = useCurrentAccount(); - const { data: auth } = useAuth({ config: { params: { address } } }); + const { accountNonce } = useNonceSync(); const { fields } = formProps; const [customFee, setCustomFee] = useState({}); const [feedback, setFeedBack] = useState(formProps.feedback); @@ -65,7 +65,7 @@ const TxComposer = ({ const transactionJSON = { module, command, - nonce: auth?.data?.nonce, + nonce: accountNonce, fee: 0, senderPublicKey: pubkey, params: commandParams, diff --git a/src/modules/transaction/components/TxSignatureCollector/TxSignatureCollector.js b/src/modules/transaction/components/TxSignatureCollector/TxSignatureCollector.js index 44f4ff5348..25bf501a6d 100644 --- a/src/modules/transaction/components/TxSignatureCollector/TxSignatureCollector.js +++ b/src/modules/transaction/components/TxSignatureCollector/TxSignatureCollector.js @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { cryptography } from '@liskhq/lisk-client'; import { TertiaryButton } from '@theme/buttons'; import { useCommandSchema } from '@network/hooks'; import Icon from '@theme/Icon'; @@ -7,9 +8,10 @@ import { isEmpty } from 'src/utils/helpers'; import EnterPasswordForm from '@auth/components/EnterPasswordForm'; import { useAuth } from '@auth/hooks/queries'; import { useCurrentAccount } from '@account/hooks'; +import useNonceSync from '@auth/hooks/useNonceSync'; import HWSigning from '@hardwareWallet/components/HWSigning/HWSigning'; import styles from './txSignatureCollector.css'; -import { joinModuleAndCommand } from '../../utils'; +import { joinModuleAndCommand, fromTransactionJSON, encodeTransaction } from '../../utils'; import { MODULE_COMMANDS_NAME_MAP } from '../../configuration/moduleCommand'; import useTxInitiatorAccount from '../../hooks/useTxInitiatorAccount'; @@ -50,6 +52,7 @@ const TxSignatureCollector = ({ const moduleCommand = joinModuleAndCommand(transactionJSON); const isRegisterMultisignature = moduleCommand === MODULE_COMMANDS_NAME_MAP.registerMultisignature; + const { incrementNonce } = useNonceSync(); const txVerification = (privateKey = undefined, publicKey = undefined) => { /** * Non-multisignature account @@ -104,8 +107,16 @@ const TxSignatureCollector = ({ }); }; - const onEnterPasswordSuccess = ({ privateKey }) => + const onEnterPasswordSuccess = ({ privateKey }) => { + const paramsSchema = moduleCommandSchemas[moduleCommand]; + const transaction = fromTransactionJSON(transactionJSON, paramsSchema); + const buffer = encodeTransaction(transaction, paramsSchema); + const transactionHex = cryptography.utils.hash(buffer).toString('hex'); + if (isTransactionAuthor) { + incrementNonce(transactionHex); + } txVerification(privateKey, currentAccount?.metadata.pubkey); + }; useEffect(() => { if (!isEmpty(transactions.signedTransaction)) { diff --git a/src/modules/transaction/components/TxSignatureCollector/TxSignatureCollector.test.js b/src/modules/transaction/components/TxSignatureCollector/TxSignatureCollector.test.js index 6d6028cf19..065b537014 100644 --- a/src/modules/transaction/components/TxSignatureCollector/TxSignatureCollector.test.js +++ b/src/modules/transaction/components/TxSignatureCollector/TxSignatureCollector.test.js @@ -1,7 +1,6 @@ -import React from 'react'; -import { cryptography } from '@liskhq/lisk-client'; -import { screen, render, fireEvent, waitFor } from '@testing-library/react'; -import { renderWithStore, renderWithRouterAndStore } from 'src/utils/testHelpers'; +import { cryptography, codec } from '@liskhq/lisk-client'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { smartRender } from 'src/utils/testHelpers'; import { useCurrentAccount } from 'src/modules/account/hooks'; import mockSavedAccounts from '@tests/fixtures/accounts'; import { useCommandSchema } from '@network/hooks/useCommandsSchema'; @@ -90,9 +89,18 @@ jest.mock('@account/hooks', () => ({ useCurrentAccount: jest.fn(() => [mockCurrentAccount, mockSetCurrentAccount]), useAccounts: jest.fn(() => ({ getAccountByAddress: jest.fn().mockReturnValue(mockCurrentAccount), + getNonceByAccount: jest.fn().mockReturnValue(2), + setNonceByAccount: jest.fn(), })), })); jest.spyOn(cryptography.address, 'getLisk32AddressFromPublicKey').mockReturnValue(address); +jest.spyOn(codec.codec, 'encode'); + +const config = { + queryClient: true, + store: true, + storeInfo: mockAppState, +}; beforeAll(() => { window.Worker = WorkerMock; @@ -133,6 +141,7 @@ describe('TxSignatureCollector', () => { nextStep: jest.fn(), statusInfo: {}, }; + const txHex = 'a24f94966cf213deb90854c41cf1f27906135b7001a49e53a9722ebf5fc67481'; useCommandSchema.mockReturnValue({ moduleCommandSchemas: mockCommandParametersSchemas.data.commands.reduce( @@ -145,6 +154,7 @@ describe('TxSignatureCollector', () => { txInitiatorAccount: { ...mockAuth.data, ...mockAuth.meta, keys: { ...mockAuth.data } }, isLoading: false, }); + codec.codec.encode.mockReturnValue(txHex); beforeEach(() => { jest.clearAllMocks(); @@ -152,7 +162,7 @@ describe('TxSignatureCollector', () => { }); it('should render password input fit not used with HW', () => { - renderWithStore(TxSignatureCollector, props, mockAppState); + smartRender(TxSignatureCollector, props, config); expect(screen.getByText('Enter your account password')).toBeInTheDocument(); expect( screen.getByText('Please enter your account password to sign this transaction.') @@ -180,8 +190,9 @@ describe('TxSignatureCollector', () => { isHW: true, }, }; + const updatedConfig = { ...config, storeInfo: mockDisconnectedAppState }; useCurrentAccount.mockReturnValue([mockHWAcct, mockSetCurrentAccount]); - renderWithRouterAndStore(TxSignatureCollector, props, mockDisconnectedAppState); + smartRender(TxSignatureCollector, props, updatedConfig); expect(screen.getByText('Reconnect to hardware wallet')).toBeInTheDocument(); }); @@ -199,8 +210,9 @@ describe('TxSignatureCollector', () => { isHW: true, }, }; + const updatedConfig = { ...config, storeInfo: mockConnectedAppState }; useCurrentAccount.mockReturnValue([mockHWAcct, mockSetCurrentAccount]); - renderWithRouterAndStore(TxSignatureCollector, props, mockConnectedAppState); + smartRender(TxSignatureCollector, props, updatedConfig); expect( screen.getByText('Please confirm the transaction on your Ledger S Plus') ).toBeInTheDocument(); @@ -230,8 +242,9 @@ describe('TxSignatureCollector', () => { isHW: true, }, }; + const updatedConfig = { ...config, storeInfo: mockConnectedAppState }; useCurrentAccount.mockReturnValue([mockHWAcct, mockSetCurrentAccount]); - renderWithStore(TxSignatureCollector, props, mockConnectedAppState); + smartRender(TxSignatureCollector, props, updatedConfig); expect(props.actionFunction).toHaveBeenCalled(); }); @@ -244,7 +257,7 @@ describe('TxSignatureCollector', () => { command: 'registerMultisignature', }, }; - renderWithStore(TxSignatureCollector, formProps, mockAppState); + smartRender(TxSignatureCollector, formProps, config); fireEvent.change(screen.getByPlaceholderText('Enter password'), { target: { value: 'DeykUBjUn7uZHYv!' }, }); @@ -255,7 +268,7 @@ describe('TxSignatureCollector', () => { }); it('should call action function on continue button click', async () => { - renderWithStore(TxSignatureCollector, props, mockAppState); + smartRender(TxSignatureCollector, props, config); fireEvent.change(screen.getByPlaceholderText('Enter password'), { target: { value: 'DeykUBjUn7uZHYv!' }, }); @@ -269,7 +282,7 @@ describe('TxSignatureCollector', () => { it('should call action function if no keys', async () => { useAuth.mockReturnValue({ isLoading: false }); - renderWithStore(TxSignatureCollector, props, mockAppState); + smartRender(TxSignatureCollector, props, config); fireEvent.change(screen.getByPlaceholderText('Enter password'), { target: { value: 'DeykUBjUn7uZHYv!' }, }); @@ -280,7 +293,7 @@ describe('TxSignatureCollector', () => { }); it('should call action function on continue button click', async () => { - renderWithStore(TxSignatureCollector, props, mockAppState); + smartRender(TxSignatureCollector, props, config); fireEvent.change(screen.getByPlaceholderText('Enter password'), { target: { value: 'DeykUBjUn7uZHYv!' }, }); @@ -298,7 +311,7 @@ describe('TxSignatureCollector', () => { txSignatureError: 'error', }, }; - render(); + smartRender(TxSignatureCollector, errorProps, config); expect(props.nextStep).toHaveBeenCalled(); }); @@ -310,7 +323,7 @@ describe('TxSignatureCollector', () => { signedTransaction: { id: '123', signatures: [] }, }, }; - renderWithStore(TxSignatureCollector, signedTransactionProps, mockAppState); + smartRender(TxSignatureCollector, signedTransactionProps, config); expect(props.nextStep).toHaveBeenCalled(); }); }); diff --git a/src/modules/transaction/components/TxSummarizer/index.js b/src/modules/transaction/components/TxSummarizer/index.js index f55bd5435c..09b5a4ba1b 100644 --- a/src/modules/transaction/components/TxSummarizer/index.js +++ b/src/modules/transaction/components/TxSummarizer/index.js @@ -1,18 +1,20 @@ -import React from 'react'; -import { MODULE_COMMANDS_NAME_MAP } from 'src/modules/transaction/configuration/moduleCommand'; -import Box from 'src/theme/box'; -import BoxHeader from 'src/theme/box/header'; -import { TertiaryButton } from 'src/theme/buttons'; -import BoxContent from 'src/theme/box/content'; -import Illustration from 'src/modules/common/components/illustration'; -import Tooltip from 'src/theme/Tooltip'; +import React, { useState } from 'react'; +import { MODULE_COMMANDS_NAME_MAP } from '@transaction/configuration/moduleCommand'; +import Box from '@theme/box'; +import BoxHeader from '@theme/box/header'; +import { TertiaryButton } from '@theme/buttons'; +import BoxContent from '@theme/box/content'; +import Illustration from '@common/components/illustration'; +import Tooltip from '@theme/Tooltip'; import { tokenMap } from '@token/fungible/consts/tokens'; -import Icon from 'src/theme/Icon'; +import Icon from '@theme/Icon'; +import useNonceSync from '@auth/hooks/useNonceSync'; import TransactionInfo from '../TransactionInfo'; import Footer from './footer'; import styles from './txSummarizer.css'; import FeeSummarizer from './FeeSummarizer'; +// eslint-disable-next-line complexity,max-statements const TxSummarizer = ({ title, children, @@ -32,6 +34,22 @@ const TxSummarizer = ({ hasNoTopCancelButton, noFeeStatus, }) => { + const [modifiedTransactionJSON, setModifiedTransactionJSON] = useState(transactionJSON); + const { onChainNonce, resetNonce } = useNonceSync(); + const isTransactionAuthor = transactionJSON.senderPublicKey === wallet.summary.publicKey; + const isNonceEqual = modifiedTransactionJSON.nonce === onChainNonce; + const nonceWarning = isTransactionAuthor && !isNonceEqual; + const canResetNonce = nonceWarning && !transactionJSON.signatures?.length; + + /* istanbul ignore next */ + const resetTxNonce = () => { + setModifiedTransactionJSON({ + ...modifiedTransactionJSON, + nonce: onChainNonce, + }); + resetNonce(); + }; + const fee = !( wallet.summary.isMultisignature || formProps.moduleCommand === MODULE_COMMANDS_NAME_MAP.registerMultisignature @@ -47,6 +65,11 @@ const TxSummarizer = ({ ), }; + const modifiedConfirmButton = { + ...confirmButton, + onClick: () => confirmButton.onClick(modifiedTransactionJSON), + }; + return ( {title && ( @@ -72,9 +95,12 @@ const TxSummarizer = ({ token={token} summaryInfo={summaryInfo} formProps={formProps} - transactionJSON={transactionJSON} + transactionJSON={modifiedTransactionJSON} account={wallet} isMultisignature={wallet.summary.isMultisignature} + nonceWarning={nonceWarning} + canResetNonce={canResetNonce} + resetTxNonce={resetTxNonce} /> {!noFeeStatus && !!fee && (
@@ -100,10 +126,20 @@ const TxSummarizer = ({
)} + {nonceWarning && ( +
+ +

+ {t( + 'Please ensure to send the previously initiated transactions. Check the transaction sequence, and broadcast them only after they have been fully signed, in their original order.' + )} +

+
+ )}