Skip to content

Commit

Permalink
feat: automatically update seed phrase size (#1099)
Browse files Browse the repository at this point in the history
Automatically detect the length of a pasted seed phrase and adjust the
selected format accordingly.

If the size of the seed phrase does not match any predefined formats,
the wallet will select the format that best fits the number of words.

### Examples
- If the user pastes a `13-word` seed phrase, the wallet will select the
`15-word` format and leave 2 blank inputs.
- If the user pastes a `24-word` seed phrase, it will select the
`24-word` format and fill out all inputs.
- In cases where the size of the seed phrase exceeds the largest
supported format or is smaller than the smallest supported format,
appropriate handling is implemented to ensure consistent behavior.

Additionally, this PR includes support for `multi-line paste`, allowing
users to input seed phrases with each word on a separate line.

| `13-words` | `24-words` |
|--------|--------|
| <video
src="https://github.com/FuelLabs/fuels-wallet/assets/7074983/979beac7-7663-44c4-a1e0-65ab6811cc94"
/> | <video
src="https://github.com/FuelLabs/fuels-wallet/assets/7074983/931014ea-af9c-4707-88bf-6e2924f6946c"
/> |
  • Loading branch information
helciofranco authored Feb 21, 2024
1 parent 15358f5 commit 82fba09
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/shy-pandas-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'fuels-wallet': minor
---

Automatically identify seed phrase length and update the selected format to fit.
99 changes: 92 additions & 7 deletions packages/app/playwright/e2e/RecoverWallet.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Browser, Page } from '@playwright/test';
import test, { chromium } from '@playwright/test';
import test, { chromium, expect } from '@playwright/test';

import {
getByAriaLabel,
Expand All @@ -13,6 +13,8 @@ import { WALLET_PASSWORD } from '../mocks';

const WORDS_12 =
'iron hammer spoon shield ahead long banana foam deposit laundry promote captain';
const WORDS_13 =
'belt old pulp zero toe turkey icon ancient exit blush iron hedgehog pact';
const WORDS_24 =
'trick modify monster anger volcano thrive jealous lens warm program milk flavor bike torch fish eye aspect cable loan little bachelor town office sound';

Expand Down Expand Up @@ -54,7 +56,6 @@ test.describe('RecoverWallet', () => {
const confirmPasswordInput = getByAriaLabel(page, 'Confirm Password');
await confirmPasswordInput.fill(WALLET_PASSWORD);
await confirmPasswordInput.press('Tab');

await getButtonByText(page, /Next/i).click();

/** Account created */
Expand All @@ -63,9 +64,94 @@ test.describe('RecoverWallet', () => {
await hasText(page, 'fuel1r...xqqj');
});

test.describe('when pasting', () => {
test('should be able to auto-select a 24-word mnemonic', async () => {
await visit(page, '/wallet');
await logout(page);
await getElementByText(page, /Import seed phrase/i).click();

/** Accept terms and conditions */
await hasText(page, /Terms of use Agreement/i);
const agreeCheckbox = getByAriaLabel(page, 'Agree with terms');
await agreeCheckbox.click();
await getButtonByText(page, /Next: Seed Phrase/i).click();

/** Select the wrong mnemonic size */
const format = await getByAriaLabel(page, 'Select format');
await format.selectOption('I have a 12 words Seed Phrase');

/** Copy words to clipboard area */
await page.evaluate(`navigator.clipboard.writeText('${WORDS_24}')`);

/** Simulating clipboard write */
await getButtonByText(page, /Paste/i).click();

/** Confirm the auto-selected mnemonic size */
expect(format).toHaveValue('24');

/** Confirm Mnemonic */
const words = WORDS_24.split(' ');
const inputs = await page.locator('input').all();
words.forEach((word, i) => {
expect(inputs[i]).toHaveValue(word);
});

/** Confirm Mnemonic */
await hasText(page, /Recover wallet/i);
await getButtonByText(page, /Paste/i).click();
await getButtonByText(page, /Next/i).click();

/** Adding password */
await hasText(page, /Create password for encryption/i);
const passwordInput = getByAriaLabel(page, 'Your Password');
await passwordInput.fill(WALLET_PASSWORD);
await passwordInput.press('Tab');
const confirmPasswordInput = getByAriaLabel(page, 'Confirm Password');
await confirmPasswordInput.fill(WALLET_PASSWORD);
await confirmPasswordInput.press('Tab');
await getButtonByText(page, /Next/i).click();

/** Account created */
await hasText(page, /Wallet created successfully/i);
await hasText(page, /Account 1/i);
await hasText(page, 'fuel1w...4rtl');
});

test('should be able to auto-select a 15-word mnemonic if pasting only 13-words', async () => {
await visit(page, '/wallet');
await logout(page);
await getElementByText(page, /Import seed phrase/i).click();

/** Accept terms and conditions */
await hasText(page, /Terms of use Agreement/i);
const agreeCheckbox = getByAriaLabel(page, 'Agree with terms');
await agreeCheckbox.click();
await getButtonByText(page, /Next: Seed Phrase/i).click();

/** Select the wrong mnemonic size */
const format = await getByAriaLabel(page, 'Select format');
await format.selectOption('I have a 12 words Seed Phrase');

/** Copy words to clipboard area */
await page.evaluate(`navigator.clipboard.writeText('${WORDS_13}')`);

/** Simulating clipboard write */
await getButtonByText(page, /Paste/i).click();

/** Confirm the auto-selected mnemonic size */
expect(format).toHaveValue('15');

/** Confirm Mnemonic */
const words = WORDS_13.split(' ');
const inputs = await page.locator('input').all();
words.forEach((word, i) => {
expect(inputs[i]).toHaveValue(word);
});
});
});

test('should be able to recover a wallet from 24-word mnemonic', async () => {
await visit(page, '/wallet');
await logout(page);
await getElementByText(page, /Import seed phrase/i).click();

/** Accept terms */
Expand All @@ -74,9 +160,9 @@ test.describe('RecoverWallet', () => {
await agreeCheckbox.click();
await getButtonByText(page, /Next: Seed Phrase/i).click();

await getByAriaLabel(page, 'Select format').selectOption(
'I have a 24 words Seed Phrase'
);
/** Select the mnemonic size */
const format = await getByAriaLabel(page, 'Select format');
await format.selectOption('I have a 24 words Seed Phrase');

/** Copy words to clipboard area */
await page.evaluate(`navigator.clipboard.writeText('${WORDS_24}')`);
Expand All @@ -97,7 +183,6 @@ test.describe('RecoverWallet', () => {
const confirmPasswordInput = getByAriaLabel(page, 'Confirm Password');
await confirmPasswordInput.fill(WALLET_PASSWORD);
await confirmPasswordInput.press('Tab');

await getButtonByText(page, /Next/i).click();

/** Account created */
Expand Down
31 changes: 23 additions & 8 deletions packages/app/src/systems/Core/components/Mnemonic/Mnemonic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const WORDS = import.meta.env.VITE_MNEMONIC_WORDS;
function fillArray(item: string[], format: number) {
return Array.from({ length: format }).map((_, idx) => item[idx] || '');
}

function splitSeedPhrase(str: string) {
return str.trim().split(/\s+/);
}

function checkMoreThanOneWord(word: string) {
if (word.split(' ').length > 1) {
const first = word.split(' ')[0];
Expand Down Expand Up @@ -57,24 +62,34 @@ export function Mnemonic({
toast.success('Seed phrase copied to clipboard');
}

function handlePastInput(
function handlePaste(words: string[]) {
const numWords = words.length;
const selectedMnemonicSize =
MNEMONIC_SIZES.find((size) => size >= numWords) ??
MNEMONIC_SIZES[MNEMONIC_SIZES.length - 1];
setFormat(selectedMnemonicSize);
setValue(fillArray(words, selectedMnemonicSize));
}

function handlePasteInput(
ev: React.ClipboardEvent<HTMLInputElement>,
idx: number
) {
const text = ev.clipboardData.getData('text/plain');
const words = text.split(' ');
const words = splitSeedPhrase(text);

// Only allow paste on the first input or
// if the paste has more than 12 words
if (idx === 0 || words.length > 11) {
const minWords = 12;
if (idx === 0 || words.length >= minWords) {
ev.preventDefault();
setValue(fillArray(words, format));
handlePaste(words);
}
}

async function handlePaste() {
async function handlePastePress() {
const text = await navigator.clipboard.readText();
setValue(fillArray(text.split(' '), format));
handlePaste(splitSeedPhrase(text));
}

function handleChange(val: string, idx: number) {
Expand Down Expand Up @@ -137,7 +152,7 @@ export function Mnemonic({
value={value[idx]}
index={idx}
onChange={handleChange}
onPaste={handlePastInput}
onPaste={handlePasteInput}
/>
</div>
</Grid>
Expand All @@ -162,7 +177,7 @@ export function Mnemonic({
size="sm"
variant="solid"
leftIcon={<Icon icon="Copy" color="intentsBase8" />}
onPress={handlePaste}
onPress={handlePastePress}
>
Paste
</Button>
Expand Down

0 comments on commit 82fba09

Please sign in to comment.