Skip to content

Commit

Permalink
Automatically correct minor mnemonic phrase errors on import (#508)
Browse files Browse the repository at this point in the history
### Description

Utilizing the functions included in `@celo/utils` in
celo-org/celo-monorepo#8034, this PR adds support for the wallet to
automatically correct minor mnemonic phrase errors, such as typos or replacement of simmilar
words, during the restore/import wallet flow.

When given an invalid mnemonic, the applications will spend up to 5 seconds searching for an
simmilar corrected mnemonic phrase. It tries suggestions by order of edit distance from the given
phrase and checks the balance of each valid mnemonic phrase it derives. If one of the phrases has a
balance, it is almost surely the intended account, so the wallet uses that phrase instead of the
invalid user given phrase. If no phrase can be found with a balance, then an error is displayed to
the user as before.

### Other changes

* Modified the error text upon input of an incorrect phrase.
* Added comments to various React compenents.
* Include cEUR where needed to allow the code to compile.
* Remove `celotool` and `celocli` commands from `package.json`
* Updates translation mocks and tests to use the parameters passed in

### Tested

* Added unit tests to ensure the new functionality works in the import saga.
* Manually tested with various phrases.


### How others should test

Using a wallet that has a balance, restore the wallet from the mnemonic phrase. (With the mnemonic
phrase, reset the application and upon relaunching it, enter the restore wallet flow) When entering
the phrase, make sure to add some errors (if you don't naturally make errors when typing). Press
submit and it the mnemonic phrase is accepted, then the feature worked.

### Related issues

- Fixes celo-org/celo-monorepo#7060
- Requires celo-org/celo-monorepo#8034
- Requires celo-org/celo-monorepo#8146

### Backwards compatibility

No concerns
  • Loading branch information
Victor "Nate" Graf authored Jul 19, 2021
1 parent e6872e6 commit 0107b7e
Show file tree
Hide file tree
Showing 59 changed files with 635 additions and 218 deletions.
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@
"postinstall": "yarn run lerna run postinstall && patch-package && yarn keys:decrypt",
"keys:decrypt": "bash scripts/key_placer.sh decrypt",
"keys:encrypt": "bash scripts/key_placer.sh encrypt",
"check:packages": "node ./scripts/check-packages.js",
"celotool": "yarn --cwd packages/celotool run --silent cli",
"celocli": "yarn --cwd packages/cli run --silent celocli"
"check:packages": "node ./scripts/check-packages.js"
},
"husky": {
"hooks": {
Expand Down
15 changes: 5 additions & 10 deletions packages/mobile/__mocks__/react-i18next.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,12 @@ const renderNodes = (reactNodes) => {
})
}

// This is useful to test that correct params were sent to i18n.
// For example, in the TransferFeedItem tests we are checking that the title of the item matches a cached value.
// Without this it's impossible to check if the used value is the cached one since it only prints the i18n key.
const printParamInsteadOfKey = {
feedItemAddress: 'address',
feedItemSentTitle: 'displayName',
feedItemGoldReceived: 'displayName',
}
// Output the key and any params sent to the translation function.
const translationFunction = (key, params) => {
const paramToPrint = printParamInsteadOfKey[key]
return paramToPrint ? params[paramToPrint] || key : key
if (typeof params !== 'object' || Object.keys(params).length === 0) {
return key
}
return [key, JSON.stringify(params)].join(', ')
}

const useMock = [translationFunction, {}]
Expand Down
7 changes: 6 additions & 1 deletion packages/mobile/__mocks__/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import hoistStatics from 'hoist-non-react-statics'
import { withTranslation as withTranslationI18Next } from 'react-i18next'

const t = (key: string) => key
const t = (key: string, params?: any) => {
if (typeof params !== 'object' || Object.keys(params).length === 0) {
return key
}
return [key, JSON.stringify(params)].join(', ')
}

export default {
language: 'EN',
Expand Down
3 changes: 2 additions & 1 deletion packages/mobile/locales/en-US/backupKeyFlow6.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"backupQuizWordCount": "What's the <0>{{ordinal}}</0> word of your Account Key?",
"importBackupFailed": "Importing Wallet Failed",
"backupQuizFailed": "The Account Key you entered is incorrect. Please try again.",
"invalidBackupPhrase": "Invalid Account Key",
"invalidBackupPhrase": "Account Key is not valid",
"invalidWordsInBackupPhrase": "Some words in the Account Key are not valid: {{ invalidWords }}",
"backupComplete": {
"header": "Backup Complete",
"0": "Success!",
Expand Down
3 changes: 2 additions & 1 deletion packages/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"@celo/react-components": "1.0.0",
"@celo/react-native-fast-crypto": "^1.8.4",
"@celo/react-native-sms-retriever": "git+https://github.com/celo-org/react-native-sms-retriever#11e078e",
"@celo/utils": "~1.2.0",
"@celo/utils": "~1.2.4",
"@celo/wallet-rpc": "~1.2.0",
"@celo/wallet-walletconnect": "~1.2.0",
"@react-native-community/async-storage": "^1.9.0",
Expand Down Expand Up @@ -177,6 +177,7 @@
"@graphql-codegen/typescript": "1.10.0",
"@graphql-codegen/typescript-operations": "1.10.0",
"@react-native-community/eslint-config": "^1.1.0",
"@redux-saga/testing-utils": "^1.1.3",
"@types/crypto-js": "^3.1.47",
"@types/ethereumjs-util": "^5.2.0",
"@types/graphql": "^14.0.7",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ exports[`RaiseLimitScreen renders correctly 1`] = `
}
}
>
dailyLimitValue
dailyLimitValue, {"dailyLimit":1000}
</Text>
<Text
style={
Expand Down
2 changes: 1 addition & 1 deletion packages/mobile/src/alert/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const showMessage = (

export const showError = (
error: ErrorMessages,
dismissAfter?: number | null,
dismissAfter?: number | null | undefined,
i18nOptions?: object
): ShowAlertAction => {
ValoraAnalytics.track(AppEvents.error_displayed, { error })
Expand Down
18 changes: 13 additions & 5 deletions packages/mobile/src/analytics/Events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,16 @@ export enum OnboardingEvents {
pin_never_set = 'pin_never_set',

wallet_import_start = 'wallet_import_start',
wallet_import_complete = 'wallet_import_complete',
wallet_import_cancel = 'wallet_import_cancel', // when a user cancels import of 0 balance wallet
wallet_import_phrase_updated = 'wallet_import_phrase_updated',
wallet_import_submit = 'wallet_import_submit',
wallet_import_cancel = 'wallet_import_cancel', // when a user cancels import of empty wallet or navigates back
wallet_import_zero_balance = 'wallet_import_zero_balance', // when the user is informed a wallet has zero balance
wallet_import_phrase_invalid = 'wallet_import_phrase_invalid',
wallet_import_phrase_correction_attempt = 'wallet_import_phrase_correction_attempt',
wallet_import_phrase_correction_success = 'wallet_import_phrase_correction_success',
wallet_import_phrase_correction_failed = 'wallet_import_phrase_correction_failed',
wallet_import_error = 'wallet_import_error',
wallet_import_success = 'wallet_import_success',

invite_redeem_start = 'invite_redeem_start',
invite_redeem_complete = 'invite_redeem_complete',
Expand All @@ -113,7 +121,7 @@ export enum OnboardingEvents {
initialize_account_complete = 'initialize_account_complete',
initialize_account_error = 'initialize_account_error',

escrow_redeem_start = 'escrow_redeem_start', // when escrow redemption starts (only happens on user invite redeemption)
escrow_redeem_start = 'escrow_redeem_start', // when escrow redemption starts (only happens on user invite redemption)
escrow_redeem_complete = 'escrow_redeem_complete',
escrow_redeem_error = 'escrow_redeem_error',

Expand Down Expand Up @@ -253,7 +261,7 @@ export enum SendEvents {
send_secure_incorrect = 'send_secure_incorrect', // when there's been an error validating the account
send_secure_complete = 'send_secure_complete', // when an account has been validated

send_secure_edit = 'send_secure_edit', // when "edit" address button is pressed to manually initate secure send flow
send_secure_edit = 'send_secure_edit', // when "edit" address button is pressed to manually initiate secure send flow

send_tx_start = 'send_tx_start',
send_tx_complete = 'send_tx_complete', // when a send or invite transaction has successfully completed
Expand Down Expand Up @@ -286,7 +294,7 @@ export enum FeeEvents {
fetch_tobin_tax_failed = 'fetch_tobin_tax_failed',
}

// Generic transaction logging to grab tx hashs
// Generic transaction logging to grab tx hashes
export enum TransactionEvents {
transaction_start = 'transaction_start',
transaction_gas_estimated = 'transaction_gas_estimated',
Expand Down
27 changes: 26 additions & 1 deletion packages/mobile/src/analytics/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,33 @@ interface OnboardingEventsProperties {
[OnboardingEvents.pin_never_set]: undefined

[OnboardingEvents.wallet_import_start]: undefined
[OnboardingEvents.wallet_import_complete]: undefined
[OnboardingEvents.wallet_import_phrase_updated]: {
wordCount: number
wordCountChange: number
}
[OnboardingEvents.wallet_import_submit]: {
useEmptyWallet: boolean
}
[OnboardingEvents.wallet_import_cancel]: undefined
[OnboardingEvents.wallet_import_zero_balance]: {
account: string
}
[OnboardingEvents.wallet_import_phrase_invalid]: {
wordCount: number
invalidWordCount: number | undefined
}
[OnboardingEvents.wallet_import_phrase_correction_attempt]: undefined
[OnboardingEvents.wallet_import_phrase_correction_success]: {
attemptNumber: number
}
[OnboardingEvents.wallet_import_phrase_correction_failed]: {
timeout: boolean
error?: string
}
[OnboardingEvents.wallet_import_error]: {
error: string
}
[OnboardingEvents.wallet_import_success]: undefined

[OnboardingEvents.invite_redeem_start]: undefined
[OnboardingEvents.invite_redeem_complete]: undefined
Expand Down
1 change: 1 addition & 0 deletions packages/mobile/src/app/ErrorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export enum ErrorMessages {
INSUFFICIENT_BALANCE = 'insufficientBalance',
INVALID_AMOUNT = 'invalidAmount',
INVALID_BACKUP_PHRASE = 'backupKeyFlow6:invalidBackupPhrase',
INVALID_WORDS_IN_BACKUP_PHRASE = 'backupKeyFlow6:invalidWordsInBackupPhrase',
IMPORT_BACKUP_FAILED = 'backupKeyFlow6:importBackupFailed',
BACKUP_QUIZ_FAILED = 'backupKeyFlow6:backupQuizFailed',
FAILED_FETCH_MNEMONIC = 'backupKeyFlow6:failedFetchMnemonic',
Expand Down
5 changes: 5 additions & 0 deletions packages/mobile/src/backup/BackupComplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const mapStateToProps = (state: RootState): StateProps => {
}
}

/**
* Component shown to the user upon completion of the Account Key setup flow. Informs the user that
* they've successfully completed the backup process and automatically returns them to where they
* came from.
*/
class BackupComplete extends React.Component<Props> {
static navigationOptions = { header: null }

Expand Down
14 changes: 14 additions & 0 deletions packages/mobile/src/backup/BackupIntroduction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const mapStateToProps = (state: RootState): StateProps => {
}
}

/**
* Component displayed to the user when entering account key flow from the settings menu or a
* notification. Displays content to the user depending on whether they have set up their account
* key backup already.
*/
class BackupIntroduction extends React.Component<Props> {
onPressBackup = () => {
ValoraAnalytics.track(OnboardingEvents.backup_start)
Expand All @@ -64,6 +69,10 @@ interface AccountKeyStartProps {
onPrimaryPress: () => void
}

/**
* Component displayed to the user when entering account key flow prior to a successful completion.
* Introduces the user to the account key and invites them to set it up
*/
function AccountKeyIntro({ onPrimaryPress }: AccountKeyStartProps) {
const { t } = useTranslation(Namespaces.backupKeyFlow6)
return (
Expand All @@ -76,6 +85,11 @@ function AccountKeyIntro({ onPrimaryPress }: AccountKeyStartProps) {
)
}

/**
* Component displayed to the user when entering the account key flow after having successfully set
* up their backup. Displays their account key and provides an option to learn more about the
* account key, which brings them to the account key education flow.
*/
function AccountKeyPostSetup() {
const accountKey = useAccountKey()

Expand Down
39 changes: 15 additions & 24 deletions packages/mobile/src/backup/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import { formatNonAccentedCharacters, validateMnemonic } from '@celo/utils/lib/account'
import { validateMnemonic } from '@celo/utils/lib/account'
import * as bip39 from 'react-native-bip39'
import { formatBackupPhraseOnEdit, formatBackupPhraseOnSubmit } from 'src/backup/utils'

describe('Mnemonic validation and formatting', () => {
const SPANISH_MNEMONIC = 'avance colmo poema momia cofre pata res verso secta cinco tubería yacer eterno observar ojo tabaco seta ruina bebé oral miembro gato suelo violín'.normalize(
'NFD'
)
const SPANISH_MNEMONIC =
'avance colmo poema momia cofre pata res verso secta cinco tubería yacer eterno observar ojo tabaco seta ruina bebé oral miembro gato suelo violín'

const SPANISH_MNEMONIC_NO_ACCENTS = 'avance colmo poema momia cofre pata res verso secta cinco tuberia yacer eterno observar ojo tabaco seta ruina bebe oral miembro gato suelo violin'.normalize(
'NFD'
)
const SPANISH_MNEMONIC_NO_ACCENTS =
'avance colmo poema momia cofre pata res verso secta cinco tuberia yacer eterno observar ojo tabaco seta ruina bebe oral miembro gato suelo violin'

const BAD_SPANISH_MNEMONIC = 'avance colmo poema momia cofre pata res verso secta cinco tuberia yacer eterno observar ojo tabaco seta ruina bebé oralio miembro gato suelo violín'.normalize(
'NFD'
)
const BAD_SPANISH_MNEMONIC =
'avance colmo poema momia cofre pata res verso secta cinco tuberia yacer eterno observar ojo tabaco seta ruina bebé oralio miembro gato suelo violín'

const PORTUGUESE_MNEMONIC =
'cheiro lealdade duplo oposto vereador acessar lanche regra prefeito apego ratazana piedade alarme marmita subsolo brochura honrado viajar magnata canoa sarjeta terno cimento prezar'
Expand Down Expand Up @@ -54,48 +51,42 @@ inner surprise invest`
})

it('validates spanish successfully', () => {
const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(SPANISH_MNEMONIC))
const mnemonic = formatBackupPhraseOnSubmit(SPANISH_MNEMONIC)
expect(validateMnemonic(mnemonic, bip39)).toBeTruthy()
})

it('validates spanish successfully without mnemonic accents', () => {
const mnemonic = formatNonAccentedCharacters(
formatBackupPhraseOnSubmit(SPANISH_MNEMONIC_NO_ACCENTS)
)
const mnemonic = formatBackupPhraseOnSubmit(SPANISH_MNEMONIC_NO_ACCENTS)
expect(validateMnemonic(mnemonic, bip39)).toBeTruthy()
})

it('validates portuguese successfully', () => {
const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(PORTUGUESE_MNEMONIC))
const mnemonic = formatBackupPhraseOnSubmit(PORTUGUESE_MNEMONIC)
expect(validateMnemonic(mnemonic, bip39)).toBeTruthy()
})

it('validates english successfully', () => {
const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(ENGLISH_MNEMONIC))
const mnemonic = formatBackupPhraseOnSubmit(ENGLISH_MNEMONIC)
expect(validateMnemonic(mnemonic, bip39)).toBeTruthy()
})

it('validates english multiline successfully', () => {
const mnemonic = formatNonAccentedCharacters(
formatBackupPhraseOnSubmit(MULTILINE_ENGLISH_MNEMONIC)
)
const mnemonic = formatBackupPhraseOnSubmit(MULTILINE_ENGLISH_MNEMONIC)
expect(validateMnemonic(mnemonic, bip39)).toBeTruthy()
})

it('does not validate bad english', () => {
const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(BAD_ENGLISH_MNEMONIC))
const mnemonic = formatBackupPhraseOnSubmit(BAD_ENGLISH_MNEMONIC)
expect(validateMnemonic(mnemonic, bip39)).toBeFalsy()
})

it('does not validate bad spanish', () => {
const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(BAD_SPANISH_MNEMONIC))
const mnemonic = formatBackupPhraseOnSubmit(BAD_SPANISH_MNEMONIC)
expect(validateMnemonic(mnemonic, bip39)).toBeFalsy()
})

it('does not validate bad portuguese', () => {
const mnemonic = formatNonAccentedCharacters(
formatBackupPhraseOnSubmit(BAD_PORTUGUESE_MNEMONIC)
)
const mnemonic = formatBackupPhraseOnSubmit(BAD_PORTUGUESE_MNEMONIC)
expect(validateMnemonic(mnemonic, bip39)).toBeFalsy()
})
})
12 changes: 7 additions & 5 deletions packages/mobile/src/backup/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MnemonicLanguages } from '@celo/utils/lib/account'
import { MnemonicLanguages, normalizeMnemonic } from '@celo/utils/lib/account'
import CryptoJS from 'crypto-js'
import { useAsync } from 'react-async-hook'
import { useDispatch, useSelector } from 'react-redux'
Expand Down Expand Up @@ -89,20 +89,22 @@ export function useAccountKey(): string | null {
return asyncAccountKey.result || null
}

export function countMnemonicWords(phrase: string): number {
return [...phrase.trim().split(/\s+/)].length
}

// Because of a RN bug, we can't fully clean the text as the user types
// https://github.com/facebook/react-native/issues/11068
export function formatBackupPhraseOnEdit(phrase: string) {
return phrase.replace(/\s+/gm, ' ')
}

// Note(Ashish) The wordlists seem to use NFD and contains lower-case words for English and Spanish.
// I am not sure if the words are lower-case for Japanese as well but I am assuming that for now.
export function formatBackupPhraseOnSubmit(phrase: string) {
return formatBackupPhraseOnEdit(phrase).trim().normalize('NFD').toLocaleLowerCase()
return normalizeMnemonic(phrase)
}

function isValidMnemonic(phrase: string, length: number) {
return !!phrase && formatBackupPhraseOnEdit(phrase).trim().split(/\s+/g).length === length
return !!phrase && countMnemonicWords(formatBackupPhraseOnEdit(phrase)) === length
}

export function isValidBackupPhrase(phrase: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ exports[` renders correctly with address but no name nor number 1`] = `
]
}
>
walletFlow5:feedItemAddress
walletFlow5:feedItemAddress, {"address":"0x0000...7E57"}
</Text>
<Text
ellipsizeMode="tail"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ exports[`TokenBottomSheet renders correctly 1`] = `
}
}
>
stableBalance
stableBalance, {"token":"cUSD"}
</Text>
<View
style={
Expand Down Expand Up @@ -222,7 +222,7 @@ exports[`TokenBottomSheet renders correctly 1`] = `
}
}
>
stableBalance
stableBalance, {"token":"cEUR"}
</Text>
<View
style={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ exports[`ConsumerIncentivesHomeScreen renders correctly 1`] = `
}
}
>
summary
summary, {"percent":5}
</Text>
<View
style={
Expand Down Expand Up @@ -285,7 +285,7 @@ exports[`ConsumerIncentivesHomeScreen renders correctly 1`] = `
}
}
>
saveMoreEarnMore.text
saveMoreEarnMore.text, {"maxSaving":50,"rewardsMax":1000}
</Text>
</View>
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ exports[`EscrowedPaymentReminderNotification renders correctly 1`] = `
}
testID="EscrowedPaymentListItem/Title"
>
escrowPaymentNotificationTitle
escrowPaymentNotificationTitle, {"mobile":"John Doe"}
</Text>
<Text
numberOfLines={1}
Expand Down
Loading

0 comments on commit 0107b7e

Please sign in to comment.