Skip to content

Commit

Permalink
feat: mnemonic validation on wallet creation (#85)
Browse files Browse the repository at this point in the history
Closes #13
  • Loading branch information
Esya authored May 24, 2021
1 parent b1f8f30 commit acb2ed1
Show file tree
Hide file tree
Showing 13 changed files with 355 additions and 107 deletions.
38 changes: 22 additions & 16 deletions cypress/integration/create-wallet.ts
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()
})
})
}
})
});
38 changes: 28 additions & 10 deletions src/app/components/MnemonicGrid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { useContext } from 'react'
interface WordProp {
id: number
word: string
hidden?: boolean
higlighted?: boolean
}

let noSelect: React.CSSProperties = {
Expand All @@ -21,38 +23,54 @@ let noSelect: React.CSSProperties = {

function MnemonicWord(props: WordProp) {
return (
<Box background="background-contrast" margin="xsmall" direction="row" pad="xsmall">
<Box
background={props.higlighted ? 'brand' : 'background-contrast'}
margin="xsmall"
direction="row"
pad="xsmall"
border={{ side: 'bottom' }}
>
<Box pad={{ right: 'small' }}>
<Text style={noSelect}>{props.id}.</Text>
</Box>
<Box>
<strong>{props.word}</strong>
<strong>{props.hidden ? '' : props.word}</strong>
</Box>
</Box>
)
}

interface Props {
mnemonic: string
// List of words
mnemonic: string[]

/** Indexes of hidden words, used for mnemonic validation */
hiddenWords?: number[]

/** Highlighted word indexes, used for mnemonic validation */
highlightedIndex?: number
}

export function MnemonicGrid({ mnemonic }: Props) {
export function MnemonicGrid({ mnemonic, highlightedIndex: hilightedIndex, hiddenWords }: Props) {
const size = useContext(ResponsiveContext)
const columns = {
small: ['1fr', '1fr'],
medium: ['1fr', '1fr', '1fr'],
large: ['1fr', '1fr', '1fr', '1fr'],
}

const words = mnemonic!
.split(' ')
.map(word => word.trim())
.filter(word => word !== '')
const words = mnemonic!.map(word => word.trim()).filter(word => word !== '')

return (
<Grid columns={columns[size]}>
<Grid columns={columns[size]} data-testid="mnemonic-grid">
{words.map((word, index) => (
<MnemonicWord key={index + 1} id={index + 1} word={word}></MnemonicWord>
<MnemonicWord
key={index + 1}
id={index + 1}
word={word}
higlighted={index === hilightedIndex}
hidden={hiddenWords && hiddenWords.indexOf(index) !== -1}
/>
))}
</Grid>
)
Expand Down
39 changes: 39 additions & 0 deletions src/app/components/MnemonicValidation/__tests__/index.test.tsx
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()
})
})
138 changes: 138 additions & 0 deletions src/app/components/MnemonicValidation/index.tsx
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
}
2 changes: 1 addition & 1 deletion src/app/pages/AccountPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function AccountPage(props: Props) {
{active && <TransactionModal />}
{(stake.loading || account.loading) && (
<Layer position="center" responsive={false}>
<Box pad="medium" gap="medium" direction="row" align="center">
<Box pad="medium" gap="medium" direction="row" align="center" background="background-front">
<Spinner size="medium" />
<Text size="large">{t('account.loading', 'Loading account')}</Text>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ exports[`<CreateWalletPage /> should match snapshot 1`] = `
max-width: 100%;
margin: 6px;
background: #33333310;
border-bottom: solid 1px rgba(0,0,0,0.33);
min-width: 0;
min-height: 0;
-webkit-flex-direction: row;
Expand Down Expand Up @@ -582,6 +583,12 @@ exports[`<CreateWalletPage /> should match snapshot 1`] = `
}
}
@media only screen and (max-width:768px) {
.c3 {
border-bottom: solid 1px rgba(0,0,0,0.33);
}
}
@media only screen and (max-width:768px) {
.c3 {
padding: 3px;
Expand Down Expand Up @@ -622,6 +629,7 @@ exports[`<CreateWalletPage /> should match snapshot 1`] = `
>
<div
class="c2"
data-testid="mnemonic-grid"
>
<div
class="c3"
Expand Down Expand Up @@ -1156,7 +1164,9 @@ exports[`<CreateWalletPage /> should match snapshot 1`] = `
class="c7"
style="word-spacing: 14px;"
>
<strong>
<strong
data-testid="generated-mnemonic"
>
test test test test test test test test test test test test test test test test test test test test test test test test
</strong>
<div
Expand Down
Loading

0 comments on commit acb2ed1

Please sign in to comment.