diff --git a/src/locales/en/common.json b/src/locales/en/common.json index f6f00555bf..683a271ad4 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -293,6 +293,7 @@ "Invalid address or public key": "Invalid address or public key", "Invalid amount": "Invalid amount", "Invalid dates": "Invalid dates", + "Invalid path format": "Invalid path format", "Keep it safe as it is the only way to access your wallet.": "Keep it safe as it is the only way to access your wallet.", "Keep up-to-date with announcements from the Lisk Foundation. Check what network delegates have been up to with dedicated profile pages.": "Keep up-to-date with announcements from the Lisk Foundation. Check what network delegates have been up to with dedicated profile pages.", "LSK deposited": "LSK deposited", diff --git a/src/modules/account/components/AddAccountBySecretRecovery/AddAccountBySecretRecovery.js b/src/modules/account/components/AddAccountBySecretRecovery/AddAccountBySecretRecovery.js index 2a6de0b76f..089a1f2fde 100644 --- a/src/modules/account/components/AddAccountBySecretRecovery/AddAccountBySecretRecovery.js +++ b/src/modules/account/components/AddAccountBySecretRecovery/AddAccountBySecretRecovery.js @@ -13,10 +13,12 @@ import styles from './AddAccountBySecretRecovery.css'; const AddAccountBySecretRecovery = ({ history }) => { const multiStepRef = useRef(null); const [recoveryPhrase, setRecoveryPhrase] = useState(null); + const [customDerivationPath, setCustomDerivationPath] = useState(); const [currentAccount, setCurrentAccount] = useCurrentAccount(); const { setAccount } = useAccounts(); - const onAddAccount = (recoveryPhraseData) => { + const onAddAccount = (recoveryPhraseData, derivationPath) => { setRecoveryPhrase(recoveryPhraseData); + setCustomDerivationPath(derivationPath); multiStepRef.current.next(); }; @@ -37,7 +39,7 @@ const AddAccountBySecretRecovery = ({ history }) => { ref={multiStepRef} > - + { - const { t } = useTranslation(); const [passphrase, setPass] = useState({ value: '', isValid: false }); const setPassphrase = (value, error) => { @@ -22,11 +23,30 @@ const AddAccountForm = ({ settings, onAddAccount }) => { }); }; + const props = { settings, onAddAccount, setPassphrase, passphrase }; + + if (settings.enableCustomDerivationPath) { + return ; + } + return ; +}; + +const AddAccountFormContainer = ({ + settings, + passphrase, + setPassphrase, + onAddAccount, + isSubmitDisabled, + derivationPath, + children, +}) => { + const { t } = useTranslation(); + const onFormSubmit = (e) => { e.preventDefault(); // istanbul ignore else if (passphrase.value && passphrase.isValid) { - onAddAccount(passphrase); + onAddAccount(passphrase, derivationPath); } }; @@ -34,6 +54,11 @@ const AddAccountForm = ({ settings, onAddAccount }) => { if (e.charCode === 13) onFormSubmit(e); }; + const passphraseArray = useMemo( + () => passphrase.value?.replace(/\W+/g, ' ')?.split(/\s/), + [passphrase.value] + ); + return (
@@ -52,20 +77,21 @@ const AddAccountForm = ({ settings, onAddAccount }) => {
12 ? 24 : 12} maxInputsLength={24} onFill={setPassphrase} keyPress={handleKeyPress} + values={passphraseArray} />
- + {children}
{t('Continue')} @@ -82,4 +108,30 @@ const AddAccountForm = ({ settings, onAddAccount }) => { ); }; +const AddAccountFormWithDerivationPath = (props) => { + const [derivationPath, setDerivationPath] = useState(defaultDerivationPath); + const derivationPathErrorMessage = useMemo( + () => getDerivationPathErrorMessage(derivationPath), + [derivationPath] + ); + + const onDerivationPathChange = (value) => { + setDerivationPath(value); + }; + + return ( + + + + ); +}; + export default AddAccountForm; diff --git a/src/modules/account/components/AddAccountForm/AddAccountForm.test.js b/src/modules/account/components/AddAccountForm/AddAccountForm.test.js index c90d053ba9..6cb3781255 100644 --- a/src/modules/account/components/AddAccountForm/AddAccountForm.test.js +++ b/src/modules/account/components/AddAccountForm/AddAccountForm.test.js @@ -20,7 +20,7 @@ beforeEach(() => { }); }); -describe('Generals', () => { +describe('AddAccountForm', () => { it('should render successfully', () => { expect(screen.getByText('Add account')).toBeTruthy(); expect( @@ -85,19 +85,52 @@ describe('Generals', () => { expect(screen.queryByText('Select Network')).toBeTruthy(); }); - it('should render the custom derivation path field with no default value', () => { - jest.clearAllMocks(); - accountFormInstance = renderWithStore(AddAccountForm, props, { - settings: { enableCustomDerivationPath: true }, + it('should have disabled button if derivation path has an error', () => { + props.settings.enableCustomDerivationPath = true; + accountFormInstance.rerender(); + + const input = screen.getByLabelText('Custom derivation path'); + fireEvent.change(input, { target: { value: 'incorrectPath' } }); + + const passphraseInput1 = screen.getByTestId('recovery-1'); + const pasteEvent = createEvent.paste(passphraseInput1, { + clipboardData: { + getData: () => + 'below record evolve eye youth post control consider spice swamp hidden easily', + }, }); + fireEvent(passphraseInput1, pasteEvent); - expect(accountFormInstance.getByDisplayValue(defaultDerivationPath)).toBeTruthy(); + expect(screen.getByText('Continue')).toHaveAttribute('disabled'); }); - it('should render the custom derivation path field with default value', () => { - accountFormInstance = renderWithStore(AddAccountForm, props, { - settings: { enableCustomDerivationPath: true, customDerivationPath: `m/0/2'` }, + it('should trigger add account if derivation path and passphrase is correct', () => { + props.settings.enableCustomDerivationPath = true; + accountFormInstance.rerender(); + + const input = screen.getByLabelText('Custom derivation path'); + const correctDerivationPath = "m/44'/134'/0'"; + fireEvent.change(input, { target: { value: correctDerivationPath } }); + + const passphrase = 'below record evolve eye youth post control consider spice swamp hidden easily'; + const passphraseInput1 = screen.getByTestId('recovery-1'); + const pasteEvent = createEvent.paste(passphraseInput1, { + clipboardData: { + getData: () => + passphrase, + }, }); - expect(screen.getByDisplayValue(`m/0/2'`)).toBeTruthy(); + fireEvent(passphraseInput1, pasteEvent); + + fireEvent.click(screen.getByText('Continue')); + + expect(props.onAddAccount).toHaveBeenCalledWith({ value: passphrase, isValid: true }, correctDerivationPath); + }); + + it('should render the custom derivation path field with no default value', () => { + props.settings.enableCustomDerivationPath = true; + accountFormInstance.rerender(); + + expect(accountFormInstance.getByDisplayValue(defaultDerivationPath)).toBeTruthy(); }); }); diff --git a/src/modules/account/hooks/useEncryptAccount.js b/src/modules/account/hooks/useEncryptAccount.js index f57062ff44..cf1548811c 100644 --- a/src/modules/account/hooks/useEncryptAccount.js +++ b/src/modules/account/hooks/useEncryptAccount.js @@ -3,8 +3,8 @@ import { selectSettings } from 'src/redux/selectors'; import { encryptAccount as encryptAccountUtils } from '@account/utils'; // eslint-disable-next-line -export function useEncryptAccount() { - const { enableCustomDerivationPath, customDerivationPath } = useSelector(selectSettings); +export function useEncryptAccount(customDerivationPath) { + const { enableCustomDerivationPath } = useSelector(selectSettings); const encryptAccount = ({ recoveryPhrase, password, name }) => encryptAccountUtils({ recoveryPhrase, password, diff --git a/src/modules/auth/components/CustomDerivationPath/index.js b/src/modules/auth/components/CustomDerivationPath/index.js index 24730abd68..2c549ee11f 100644 --- a/src/modules/auth/components/CustomDerivationPath/index.js +++ b/src/modules/auth/components/CustomDerivationPath/index.js @@ -1,32 +1,25 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useDispatch, useSelector } from 'react-redux'; -import { settingsUpdated } from 'src/redux/actions'; -import { selectSettings } from 'src/redux/selectors'; import { Input } from 'src/theme'; -import { defaultDerivationPath } from 'src/utils/explicitBipKeyDerivation'; -import styles from '../Signin/login.css'; -const CustomDerivationPath = () => { - const { enableCustomDerivationPath, customDerivationPath } = useSelector(selectSettings); - const dispatch = useDispatch(); +const CustomDerivationPath = ({ onChange, value, errorMessage }) => { const { t } = useTranslation(); const onPathInputChange = (e) => { - dispatch(settingsUpdated({ customDerivationPath: e.target.value })); + onChange(e.target.value); }; - if (!enableCustomDerivationPath) return null; - return (
- +
); diff --git a/src/modules/auth/components/CustomDerivationPath/index.test.js b/src/modules/auth/components/CustomDerivationPath/index.test.js new file mode 100644 index 0000000000..a232002450 --- /dev/null +++ b/src/modules/auth/components/CustomDerivationPath/index.test.js @@ -0,0 +1,23 @@ +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; + +import CustomDerivationPath from './index'; + +describe('CustomDerivationPath', () => { + const props = { + onChange: jest.fn(), + value: '', + }; + + it('Should render without breaking', () => { + render(); + }); + + it('Should call onChange when input changes', () => { + const { getByLabelText } = render(); + const input = getByLabelText('Custom derivation path'); + fireEvent.change(input, { target: { value: 'hello' } }); + + expect(props.onChange).toHaveBeenCalledWith('hello'); + }); +}); diff --git a/src/modules/auth/components/SetPasswordForm/SetPasswordForm.js b/src/modules/auth/components/SetPasswordForm/SetPasswordForm.js index 65044519fb..84b4a26bf4 100644 --- a/src/modules/auth/components/SetPasswordForm/SetPasswordForm.js +++ b/src/modules/auth/components/SetPasswordForm/SetPasswordForm.js @@ -26,9 +26,9 @@ const setPasswordFormSchema = yup.object({ hasAgreed: yup.boolean().required(), }).required(); -function SetPasswordForm({ onSubmit, recoveryPhrase }) { +function SetPasswordForm({ onSubmit, recoveryPhrase, customDerivationPath }) { const { t } = useTranslation(); - const { encryptAccount } = useEncryptAccount(); + const { encryptAccount } = useEncryptAccount(customDerivationPath); const { register, handleSubmit, diff --git a/src/modules/auth/components/Signin/login.css b/src/modules/auth/components/Signin/login.css index f74561e276..b75cd2fc39 100644 --- a/src/modules/auth/components/Signin/login.css +++ b/src/modules/auth/components/Signin/login.css @@ -123,11 +123,6 @@ } } -.derivationPathInput { - height: 40px !important; - margin-top: var(--vertical-padding-m); -} - .link { @mixin contentLarge bold; diff --git a/src/modules/auth/components/Signin/login.test.js b/src/modules/auth/components/Signin/login.test.js index 86cee77121..672303fd3b 100644 --- a/src/modules/auth/components/Signin/login.test.js +++ b/src/modules/auth/components/Signin/login.test.js @@ -1,11 +1,9 @@ import React from 'react'; import i18next from 'i18next'; import { mount } from 'enzyme'; -import { useDispatch } from 'react-redux'; import { mountWithRouterAndStore } from 'src/utils/testHelpers'; import routes from 'src/routes/routes'; import { defaultDerivationPath } from 'src/utils/explicitBipKeyDerivation'; -import { settingsUpdated } from 'src/redux/actions'; import accounts from '@tests/constants/wallets'; import Login from './login'; @@ -158,33 +156,5 @@ describe('Login', () => { wrapper.find('.custom-derivation-path-input').exists(), ).toBeFalsy(); }); - - it('Should display custom derivation path and dispatch settings updated action', () => { - const mockDispatch = jest.fn(); - useDispatch.mockReturnValue(mockDispatch); - wrapper = mountWithRouterAndStore( - Login, - props, - {}, - { - settings: { - enableCustomDerivationPath: true, - customDerivationPath: defaultDerivationPath, - }, - }, - ); - - expect( - wrapper.find('.custom-derivation-path-input').at(1).props().value, - ).toBe(defaultDerivationPath); - wrapper - .find('.custom-derivation-path-input') - .at(1) - .simulate('change', { target: { value: "m/44'/134'/1'" } }); - wrapper.update(); - expect(mockDispatch).toHaveBeenCalledWith( - settingsUpdated({ customDerivationPath: "m/44'/134'/1'" }), - ); - }); }); }); diff --git a/src/modules/wallet/components/PassphraseInput/PassphraseInput.js b/src/modules/wallet/components/PassphraseInput/PassphraseInput.js index 8e86be9817..1ca1c99335 100644 --- a/src/modules/wallet/components/PassphraseInput/PassphraseInput.js +++ b/src/modules/wallet/components/PassphraseInput/PassphraseInput.js @@ -17,7 +17,7 @@ class passphraseInput extends React.Component { this.state = { showPassphrase: false, partialPassphraseError: [], - values: [], + values: props.values || [], focus: 0, validationError: '', passphraseIsInvalid: false, diff --git a/src/modules/wallet/utils/account.js b/src/modules/wallet/utils/account.js index 5d5cf61ab6..79b28523bd 100644 --- a/src/modules/wallet/utils/account.js +++ b/src/modules/wallet/utils/account.js @@ -1,6 +1,7 @@ /* eslint-disable max-lines, max-len */ import { passphrase as LiskPassphrase, cryptography } from '@liskhq/lisk-client'; import { regex } from 'src/const/regex'; +import i18next from 'i18next'; /** * Extracts Lisk PrivateKey/PublicKey pair from a given valid Mnemonic passphrase @@ -303,3 +304,48 @@ export const validate2ndPass = async (account, passphrase, error) => { } return messages; }; + +/** + * Validate a derivation path + * TODO: Replace this function when @liskhq (lisk-sdk) exposes the one they use: Issue #7877 https://github.com/LiskHQ/lisk-sdk/issues/7877 + * @param {string} derivationPath + * @returns {string} - Error message + */ +export const getDerivationPathErrorMessage = (derivationPath) => { + if ((!derivationPath || !derivationPath.startsWith('m')) || !derivationPath.includes('/')) { + return i18next.t('Invalid path format'); + } + + try { + const MAX_UINT32 = 4294967295; + const HARDENED_OFFSET = 0x80000000; + + derivationPath + .split('/') + // slice first element which is `m` + .slice(1) + .map((segment) => { + if (!/^[0-9']+$/g.test(segment)) { + throw new Error('Invalid path format'); + } + + // if segment includes apostrophe add HARDENED_OFFSET + if (segment.includes(`'`)) { + if (parseInt(segment.slice(0, -1), 10) > MAX_UINT32 / 2) { + throw new Error('Invalid path format'); + } + return parseInt(segment, 10) + HARDENED_OFFSET; + } + + if (parseInt(segment, 10) > MAX_UINT32) { + throw new Error('Invalid path format'); + } + + return parseInt(segment, 10); + }); + } catch (error) { + return i18next.t(error.message); + } + + return undefined; +}; diff --git a/src/theme/Input/input.css b/src/theme/Input/input.css index 19bd7ba3f0..e8da5017b7 100644 --- a/src/theme/Input/input.css +++ b/src/theme/Input/input.css @@ -64,7 +64,7 @@ } & .status { - top: calc(var(--height-m) / 2 + 4px); + top: calc(var(--height-m) / 2); right: var(--horizontal-padding-m); } diff --git a/src/theme/buttons/css/base.css b/src/theme/buttons/css/base.css index 2117d92a46..ba2a9a7ddf 100644 --- a/src/theme/buttons/css/base.css +++ b/src/theme/buttons/css/base.css @@ -65,6 +65,7 @@ &:disabled { opacity: 0.58; + cursor: default; } }