-
Notifications
You must be signed in to change notification settings - Fork 45
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: mnemonic validation on wallet creation (#85)
Closes #13
- Loading branch information
Showing
13 changed files
with
355 additions
and
107 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,32 +1,38 @@ | ||
import { account } from "../fixtures/account"; | ||
|
||
describe('Create wallet', () => { | ||
beforeEach(() => { | ||
let generatedMnemonic: string | ||
|
||
before(() => { | ||
cy.intercept('/data/accounts/*', { body: JSON.stringify(account) }) | ||
cy.visit('/create-wallet'); | ||
}) | ||
|
||
it('Should have generated a mnemonic', () => { | ||
cy.findByTestId("generated-mnemonic").invoke('text').then(text => { | ||
generatedMnemonic = text | ||
}) | ||
}) | ||
|
||
it('should not be able to submit without confirmation', () => { | ||
cy.findByRole('button', { name: /Open my wallet/ }).should('be.disabled') | ||
}); | ||
|
||
it('should be able to submit with confirmation', () => { | ||
cy.findByLabelText(/saved/).click({ force: true }) | ||
cy.findByRole('button', { name: /Open my wallet/ }).should('be.enabled') | ||
}); | ||
|
||
it('Should open wallet', () => { | ||
it('Should open mnemonic confirmation', () => { | ||
cy.findByLabelText(/saved/).click({ force: true }) | ||
cy.findByRole('button', { name: /Open my wallet/ }).should('be.enabled').click() | ||
cy.url().should('include', '/account') | ||
}) | ||
|
||
it('Should be able to close the wallet once opened', () => { | ||
cy.findByLabelText(/saved/).click({ force: true }) | ||
cy.findByRole('button', { name: /Open my wallet/i }).click() | ||
cy.url().should('include', '/account') | ||
cy.get('button[aria-label="Close wallet"]').click() | ||
|
||
// Back to homepage | ||
cy.findByRole('button', { name: /Create wallet/i }).should('be.visible') | ||
describe('Confirm mnemonic', () => { | ||
for(let i = 1; i <= 5; i++) { | ||
// eslint-disable-next-line no-loop-func | ||
it(`Should pick word ${i}/5`, () => { | ||
cy.findByTestId('pick-word').invoke('text').then(text => { | ||
const id = Number(text.match(/#([0-9]+)/)![1]) | ||
const missing = generatedMnemonic.split(' ')[id - 1] | ||
cy.findByRole('button', { name: missing }).click() | ||
}) | ||
}) | ||
} | ||
}) | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
39 changes: 39 additions & 0 deletions
39
src/app/components/MnemonicValidation/__tests__/index.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import { render } from '@testing-library/react' | ||
import * as React from 'react' | ||
import { Provider } from 'react-redux' | ||
import { configureAppStore } from 'store/configureStore' | ||
|
||
import { MnemonicValidation } from '..' | ||
|
||
jest.mock('bip39', () => ({ | ||
wordlists: { english: ['mock1', 'mock2', 'mock3', 'mock4', 'mock5'] }, | ||
})) | ||
|
||
const renderComponent = (store, component: React.ReactNode) => | ||
render(<Provider store={store}>{component}</Provider>) | ||
|
||
describe('<MnemonicValidation />', () => { | ||
let store: ReturnType<typeof configureAppStore> | ||
|
||
beforeEach(() => { | ||
store = configureAppStore() | ||
}) | ||
|
||
it('should match snapshot', () => { | ||
const mnemonic = 'test1 test2 test3 test4 test5 test6'.split(' ') | ||
const successHandler = jest.fn() | ||
const abortHandler = jest.fn() | ||
|
||
renderComponent( | ||
store, | ||
<MnemonicValidation | ||
validMnemonic={mnemonic} | ||
successHandler={successHandler} | ||
abortHandler={abortHandler} | ||
/>, | ||
) | ||
|
||
// @TODO The words are shuffled | ||
// expect(component).toMatchSnapshot() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
import { wordlists } from 'bip39' | ||
import { Box, Button, Grid, Heading, Layer, ResponsiveContext, Text } from 'grommet' | ||
import * as React from 'react' | ||
import { useContext, useEffect, useState } from 'react' | ||
import { useTranslation } from 'react-i18next' | ||
|
||
import { MnemonicGrid } from '../MnemonicGrid' | ||
|
||
interface Props { | ||
validMnemonic: string[] | ||
|
||
/** Called once the mnemonic is confirmed */ | ||
successHandler: () => void | ||
abortHandler: () => void | ||
} | ||
|
||
export function MnemonicValidation({ validMnemonic, successHandler, abortHandler }: Props) { | ||
const numberOfSteps = 5 | ||
const numberOfWrongWords = 5 | ||
|
||
const { t } = useTranslation() | ||
const size = useContext(ResponsiveContext) | ||
|
||
const [indexes, setIndexes] = useState<number[]>([]) | ||
const [choices, setChoices] = useState<string[]>([]) | ||
|
||
const currentStep = numberOfSteps - indexes.length + 1 | ||
|
||
useEffect(() => { | ||
// Pick 5 random indexes | ||
const randomIndexes = randIndexes(validMnemonic, numberOfSteps).sort((a, b) => a - b) | ||
setIndexes(randomIndexes) | ||
}, [validMnemonic]) | ||
|
||
useEffect(() => { | ||
// Pick random words from the wordlist with the valid word | ||
const choicesId = randIndexes(wordlists.english, numberOfWrongWords) | ||
const currentIndex = indexes[0] | ||
const validWord = validMnemonic[currentIndex] | ||
setChoices(shuffle([...choicesId.map(c => wordlists.english[c]), validWord])) | ||
}, [indexes, validMnemonic]) | ||
|
||
const wordClicked = (word: string) => { | ||
const currentIndex = indexes[0] | ||
const valid = validMnemonic[currentIndex] === word | ||
const isLastWord = indexes.length === 1 | ||
|
||
if (isLastWord && valid) { | ||
successHandler() | ||
} else if (valid) { | ||
// Move to the next one | ||
const newArr = indexes.slice() | ||
newArr.shift() | ||
|
||
setIndexes(newArr) | ||
} else { | ||
// Restart the process | ||
const randomIndexes = randIndexes(validMnemonic, numberOfSteps).sort((a, b) => a - b) | ||
setIndexes(randomIndexes) | ||
} | ||
} | ||
|
||
return ( | ||
<Layer plain full data-testid="mnemonic-validation"> | ||
<Box fill style={{ backdropFilter: 'blur(5px)' }}> | ||
<Layer background="background-front" onClickOutside={abortHandler}> | ||
<Box background="background-front" pad="medium" gap="small" overflow="auto"> | ||
<Heading size="4" margin="none"> | ||
{t('createWallet.confirmMnemonic.header', 'Confirm your mnemonic ({{progress}}/{{total}})', { | ||
progress: currentStep, | ||
total: numberOfSteps, | ||
})} | ||
</Heading> | ||
<MnemonicGrid mnemonic={validMnemonic} hiddenWords={indexes} highlightedIndex={indexes[0]} /> | ||
<Text data-testid="pick-word"> | ||
{t( | ||
'createWallet.confirmMnemonic.pickWord', | ||
'Pick the right word corresponding to word #{{index}}', | ||
{ index: indexes[0] + 1 }, | ||
)} | ||
</Text> | ||
<Grid columns={size !== 'small' ? 'small' : '100%'} gap="small"> | ||
{choices.map(w => ( | ||
<Button | ||
label={w} | ||
style={{ borderRadius: '4px' }} | ||
onClick={() => wordClicked(w)} | ||
key={`${currentStep}-${w}`} | ||
/> | ||
))} | ||
</Grid> | ||
<Box align="end" pad={{ top: 'medium' }}> | ||
<Button | ||
primary | ||
style={{ borderRadius: '4px' }} | ||
label={t('common.cancel', 'Cancel')} | ||
onClick={abortHandler} | ||
/> | ||
</Box> | ||
</Box> | ||
</Layer> | ||
</Box> | ||
</Layer> | ||
) | ||
} | ||
|
||
function randIndexes<T>(array: T[], num: number = 1): number[] { | ||
const keys = Object.keys(array) | ||
|
||
// shuffle the array of keys | ||
for (let i = keys.length - 1; i > 0; i--) { | ||
const j = Math.floor(Math.random() * (i + 1)) // 0 ≤ j ≤ i | ||
const tmp = keys[j] | ||
keys[j] = keys[i] | ||
keys[i] = tmp | ||
} | ||
return keys.slice(0, num).map(i => Number(i)) | ||
} | ||
|
||
function shuffle<T>(array: T[]): T[] { | ||
let currentIndex = array.length, | ||
temporaryValue: T, | ||
randomIndex: number | ||
|
||
// While there remain elements to shuffle... | ||
while (0 !== currentIndex) { | ||
// Pick a remaining element... | ||
randomIndex = Math.floor(Math.random() * currentIndex) | ||
currentIndex -= 1 | ||
|
||
// And swap it with the current element. | ||
temporaryValue = array[currentIndex] | ||
array[currentIndex] = array[randomIndex] | ||
array[randomIndex] = temporaryValue | ||
} | ||
|
||
return array | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.