From 98bad743202f25e28d59477daaf98afa591e2e81 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 12 Nov 2026 13:12:19 -0700 Subject: [PATCH 1/6] feat(onboarding): import a wallet from a recovery phrase or secret key Onboarding import takes a single input and detects whether it is a 12/24-word recovery phrase or a Stellar secret key. A secret key creates a wallet with one imported account, a secret-key source and no recovery phrase, through a new RECOVER_ACCOUNT_FROM_SECRET_KEY handler that shares its wipe and finish steps with recoverAccount. "Show recovery phrase" is hidden for wallets with no phrase source. The e2e login and onboarding helpers follow the new screen order. --- @shared/api/internal.ts | 37 ++ @shared/api/types/message-request.ts | 8 + @shared/constants/services.ts | 1 + extension/e2e-tests/helpers/login-pt.ts | 53 +- extension/e2e-tests/helpers/login.ts | 79 +-- extension/e2e-tests/helpers/onboarding.ts | 89 +-- extension/e2e-tests/onboarding.test.ts | 484 +++++++---------- ...rt-12-word-phrase-page-chromium-darwin.png | Bin 23709 -> 15581 bytes ...rt-24-word-phrase-page-chromium-darwin.png | Bin 24138 -> 15581 bytes extension/e2e-tests/translations-pt.test.ts | 25 +- .../helpers/account-mutation-lock.ts | 1 + .../recoverAccountFromSecretKey.test.ts | 244 +++++++++ .../handlers/recoverAccount.ts | 100 +--- .../handlers/recoverAccountFromSecretKey.ts | 106 ++++ .../helpers/recovery-lifecycle.ts | 140 +++++ .../messageListener/popupMessageListener.ts | 10 + extension/src/popup/ducks/accountServices.ts | 75 +++ .../__tests__/hasRecoveryPhrase.test.ts | 30 + .../__tests__/parseImportSecret.test.ts | 71 +++ .../src/popup/helpers/hasRecoveryPhrase.ts | 8 + .../src/popup/helpers/parseImportSecret.ts | 26 + .../src/popup/locales/en/translation.json | 10 + .../src/popup/locales/pt/translation.json | 10 + .../metrics/__tests__/authServices.test.ts | 88 +++ extension/src/popup/metrics/authServices.ts | 18 + .../popup/views/DisplayBackupPhrase/index.tsx | 8 + .../views/RecoverAccount/ImportStatus.tsx | 18 + .../src/popup/views/RecoverAccount/index.tsx | 511 +++++++++--------- .../popup/views/RecoverAccount/styles.scss | 95 +--- extension/src/popup/views/Security/index.tsx | 18 +- .../views/__tests__/RecoverAccount.test.tsx | 410 ++++++++++++-- .../popup/views/__tests__/Security.test.tsx | 93 ++++ 32 files changed, 1979 insertions(+), 887 deletions(-) create mode 100644 extension/src/background/messageListener/__tests__/recoverAccountFromSecretKey.test.ts create mode 100644 extension/src/background/messageListener/handlers/recoverAccountFromSecretKey.ts create mode 100644 extension/src/background/messageListener/helpers/recovery-lifecycle.ts create mode 100644 extension/src/popup/helpers/__tests__/hasRecoveryPhrase.test.ts create mode 100644 extension/src/popup/helpers/__tests__/parseImportSecret.test.ts create mode 100644 extension/src/popup/helpers/hasRecoveryPhrase.ts create mode 100644 extension/src/popup/helpers/parseImportSecret.ts create mode 100644 extension/src/popup/metrics/__tests__/authServices.test.ts create mode 100644 extension/src/popup/views/RecoverAccount/ImportStatus.tsx create mode 100644 extension/src/popup/views/__tests__/Security.test.tsx diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index b64a8a27dd..90cffe4ff1 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -610,6 +610,43 @@ export const recoverAccount = async ({ return { allAccounts, publicKey, activeAccountId, hasPrivateKey, error }; }; +export const recoverAccountFromSecretKey = async ({ + password, + secretKey, + isOverwritingAccount = false, +}: { + password: string; + secretKey: string; + isOverwritingAccount: boolean; +}): Promise<{ + publicKey: string; + activeAccountId: string; + allAccounts: Array; + hasPrivateKey: boolean; + error: string; +}> => { + let publicKey = ""; + let activeAccountId = ""; + let allAccounts = [] as Array; + let hasPrivateKey = false; + let error = ""; + + try { + ({ allAccounts, publicKey, activeAccountId, hasPrivateKey, error } = + await sendMessageToBackground({ + activePublicKey: null, + password, + secretKey, + isOverwritingAccount, + type: SERVICE_TYPES.RECOVER_ACCOUNT_FROM_SECRET_KEY, + })); + } catch (e) { + console.error("Failed to recover account from secret key"); + } + + return { allAccounts, publicKey, activeAccountId, hasPrivateKey, error }; +}; + export const confirmPassword = async ( password: string, ): Promise<{ diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 2f61844b9d..4eb0facc9b 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -241,6 +241,13 @@ export interface RecoverAccountMessage extends BaseMessage { recoverMnemonic: string; } +export interface RecoverAccountFromSecretKeyMessage extends BaseMessage { + type: SERVICE_TYPES.RECOVER_ACCOUNT_FROM_SECRET_KEY; + isOverwritingAccount: boolean; + password: string; + secretKey: string; +} + export interface ShowBackupPhraseMessage extends BaseMessage { type: SERVICE_TYPES.SHOW_BACKUP_PHRASE; password: string; @@ -613,6 +620,7 @@ export type ServiceMessageRequest = | ConfirmMnemonicPhraseMessage | ConfirmMigratedMnemonicPhraseMessage | RecoverAccountMessage + | RecoverAccountFromSecretKeyMessage | ShowBackupPhraseMessage | RevealSecretKeyMessage | ConfirmPasswordMessage diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 6597487b44..b534b42105 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -14,6 +14,7 @@ export enum SERVICE_TYPES { CONFIRM_MNEMONIC_PHRASE = "CONFIRM_MNEMONIC_PHRASE", CONFIRM_MIGRATED_MNEMONIC_PHRASE = "CONFIRM_MIGRATED_MNEMONIC_PHRASE", RECOVER_ACCOUNT = "RECOVER_ACCOUNT", + RECOVER_ACCOUNT_FROM_SECRET_KEY = "RECOVER_ACCOUNT_FROM_SECRET_KEY", CONFIRM_PASSWORD = "CONFIRM_PASSWORD", REJECT_ACCESS = "REJECT_ACCESS", GRANT_ACCESS = "GRANT_ACCESS", diff --git a/extension/e2e-tests/helpers/login-pt.ts b/extension/e2e-tests/helpers/login-pt.ts index 8e2724f60e..5c53b0a07b 100644 --- a/extension/e2e-tests/helpers/login-pt.ts +++ b/extension/e2e-tests/helpers/login-pt.ts @@ -1,8 +1,14 @@ import { expect } from "../test-fixtures"; import { Page } from "@playwright/test"; import { switchNetwork } from "./network"; +import { PASSWORD, TEST_ACCOUNT_WORDS } from "./login"; +import { + enterImportSecret, + fillPasswordAndConfirm, + waitForImportSuccess, +} from "./onboarding"; -export const PASSWORD = "My-password123"; +export { PASSWORD }; export const loginToTestAccountPT = async ({ page, @@ -13,40 +19,27 @@ export const loginToTestAccountPT = async ({ }) => { await page.goto(`chrome-extension://${extensionId}/index.html`); await page.getByText("Importar carteira").click(); - - await expect(page.getByText("Criar uma Senha")).toBeVisible(); - - await page.locator("#new-password-input").fill("My-password123"); - await page.locator("#confirm-password-input").fill("My-password123"); - await page.locator("#termsOfUse-input").check({ force: true }); - await page.getByText("Confirmar").click(); + await expect( + page.getByText( + "Importe sua carteira usando uma frase de recuperação ou chave secreta", + ), + ).toBeVisible(); // GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY + await enterImportSecret({ + page, + secret: TEST_ACCOUNT_WORDS.join(" "), + }); - const TEST_ACCOUNT_WORDS = [ - "card", - "whip", - "erosion", - "fatal", - "reunion", - "foil", - "doctor", - "embark", - "plug", - "note", - "thank", - "company", - ]; - - for (let i = 1; i <= TEST_ACCOUNT_WORDS.length; i++) { - await page.locator(`#MnemonicPhrase-${i}`).fill(TEST_ACCOUNT_WORDS[i - 1]); - } - - await page.getByRole("button", { name: "Importar" }).click(); - await expect(page.getByText("Tudo pronto!")).toBeVisible({ - timeout: 20000, + await expect(page.getByText("Criar uma Senha")).toBeVisible(); + await fillPasswordAndConfirm({ + page, + password: PASSWORD, + confirmLabel: "Confirmar", }); + await waitForImportSuccess({ page, successText: "Tudo pronto!" }); + await page.goto(`chrome-extension://${extensionId}/index.html#/`); await expect(page.getByTestId("account-chip")).toBeVisible({ timeout: 50000, diff --git a/extension/e2e-tests/helpers/login.ts b/extension/e2e-tests/helpers/login.ts index d1c98b1fbc..c24316ba16 100644 --- a/extension/e2e-tests/helpers/login.ts +++ b/extension/e2e-tests/helpers/login.ts @@ -3,11 +3,27 @@ import { Page, BrowserContext } from "@playwright/test"; import { expect } from "../test-fixtures"; import { stubAllExternalApis } from "./stubs"; import { switchNetwork } from "./network"; +import { importWallet } from "./onboarding"; const { generateMnemonic } = StellarHDWallet; export const PASSWORD = "My-password123"; +export const TEST_ACCOUNT_WORDS = [ + "card", + "whip", + "erosion", + "fatal", + "reunion", + "foil", + "doctor", + "embark", + "plug", + "note", + "thank", + "company", +]; + /** * Creates a new wallet and logs into the extension on Testnet. */ @@ -19,32 +35,11 @@ export const login = async ({ extensionId: string; }) => { await page.goto(`chrome-extension://${extensionId}/index.html`); - await page.getByText("Import wallet").click(); - - await expect(page.getByText("Create a Password")).toBeVisible(); - - await page.locator("#new-password-input").fill("My-password123"); - await page.locator("#confirm-password-input").fill("My-password123"); - await page.locator("#termsOfUse-input").check({ force: true }); - await page.getByText("Confirm").click(); - - await expect( - page.getByText("Import wallet from recovery phrase"), - ).toBeVisible(); - await expect( - page.getByText("You can also paste the phrase in the first box."), - ).toBeVisible(); - const TEST_WORDS = generateMnemonic({ entropyBits: 128 }).split(" "); - - for (let i = 1; i <= TEST_WORDS.length; i++) { - await page.locator(`#MnemonicPhrase-${i}`).fill(TEST_WORDS[i - 1]); - } - - await page.getByRole("button", { name: "Import" }).click(); - - await expect(page.getByText("You’re all set!")).toBeVisible({ - timeout: 20000, + await importWallet({ + page, + secret: TEST_WORDS.join(" "), + password: PASSWORD, }); await page.goto(`chrome-extension://${extensionId}/index.html#/`); @@ -127,37 +122,11 @@ export const loginToTestAccount = async ({ await stubOverrides(); } } - await page.getByText("Import wallet").click(); - - await page.locator("#new-password-input").fill("My-password123"); - await page.locator("#confirm-password-input").fill("My-password123"); - await page.locator("#termsOfUse-input").check({ force: true }); - await page.getByText("Confirm").click(); - // GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY - - const TEST_ACCOUNT_WORDS = [ - "card", - "whip", - "erosion", - "fatal", - "reunion", - "foil", - "doctor", - "embark", - "plug", - "note", - "thank", - "company", - ]; - - for (let i = 1; i <= TEST_ACCOUNT_WORDS.length; i++) { - await page.locator(`#MnemonicPhrase-${i}`).fill(TEST_ACCOUNT_WORDS[i - 1]); - } - - await page.getByRole("button", { name: "Import" }).click(); - await expect(page.getByText("You’re all set!")).toBeVisible({ - timeout: 20000, + await importWallet({ + page, + secret: TEST_ACCOUNT_WORDS.join(" "), + password: PASSWORD, }); await page.goto(`chrome-extension://${extensionId}/index.html#/`); diff --git a/extension/e2e-tests/helpers/onboarding.ts b/extension/e2e-tests/helpers/onboarding.ts index ceac5b7e05..c327aa8453 100644 --- a/extension/e2e-tests/helpers/onboarding.ts +++ b/extension/e2e-tests/helpers/onboarding.ts @@ -1,74 +1,91 @@ import { Page } from "@playwright/test"; import { expect } from "../test-fixtures"; -const DEFAULT_IMPORT_TIMEOUT_MS = 10000; +const DEFAULT_IMPORT_TIMEOUT_MS = 20000; /** - * Starts the import wallet flow by clicking "Import wallet", - * filling in password and confirm password, checking terms of use, and clicking Confirm. - * Waits for the "Import wallet from recovery phrase" screen to be visible. + * Clicks "Import wallet" on the Welcome page and waits for the import text + * box. + */ +export const openImportWallet = async ({ page }: { page: Page }) => { + await page.getByText("Import wallet").click(); + await expect(page.getByTestId("import-secret-input")).toBeVisible(); +}; + +/** + * Fills the import text box with a recovery phrase or secret key and clicks + * Next. Does not wait for the password step, so callers can assert an + * input error instead. + */ +export const enterImportSecret = async ({ + page, + secret, +}: { + page: Page; + secret: string; +}) => { + await page.getByTestId("import-secret-input").fill(secret); + await page.getByTestId("import-secret-next").click(); +}; + +/** + * Fills the password step (password, confirmation, terms of use) and clicks + * Confirm. * - * @param confirmPassword - Optional. If not provided, defaults to the same value as password. - * Use a different value to test password mismatch scenarios. + * @param confirmPassword - Optional. Defaults to `password`. */ -export const startImportWalletFlow = async ({ +export const fillPasswordAndConfirm = async ({ page, password, confirmPassword, + confirmLabel = "Confirm", }: { page: Page; password: string; confirmPassword?: string; + confirmLabel?: string; }) => { - await page.getByText("Import wallet").click(); - await expect(page.getByText("Create a password")).toBeVisible(); - + await expect(page.locator("#new-password-input")).toBeVisible(); await page.locator("#new-password-input").fill(password); await page .locator("#confirm-password-input") .fill(confirmPassword ?? password); await page.locator("#termsOfUse-input").check({ force: true }); - await page.getByText("Confirm").click(); - - await expect( - page.getByText("Import wallet from recovery phrase"), - ).toBeVisible(); - await expect( - page.getByText("You can also paste the phrase in the first box."), - ).toBeVisible(); + await page.getByText(confirmLabel, { exact: true }).click(); }; /** - * Fills in mnemonic phrase inputs with the provided words. - * Words are filled sequentially into inputs named MnemonicPhrase-1, MnemonicPhrase-2, etc. + * Waits for the import to land on the "You’re all set!" success route. */ -export const fillMnemonicInputs = async ({ +export const waitForImportSuccess = async ({ page, - words, + timeout = DEFAULT_IMPORT_TIMEOUT_MS, + successText = "You’re all set!", }: { page: Page; - words: string[]; + timeout?: number; + successText?: string; }) => { - for (let i = 1; i <= words.length; i++) { - const input = page.locator(`input[name="MnemonicPhrase-${i}"]`); - await input.fill(words[i - 1]); - } + await expect(page.getByText(successText)).toBeVisible({ timeout }); }; /** - * Clicks the Import button and waits for the "You're all set!" message to appear, - * indicating successful wallet import. - * - * @param timeout - Optional. Time in milliseconds to wait for success message. - * Defaults to DEFAULT_IMPORT_TIMEOUT_MS (10000ms). + * Runs the whole import from the Welcome page: text box, Next, password + * step, then waits for the success route. */ -export const clickImportAndWaitForSuccess = async ({ +export const importWallet = async ({ page, - timeout = DEFAULT_IMPORT_TIMEOUT_MS, + secret, + password, + timeout, }: { page: Page; + secret: string; + password: string; timeout?: number; }) => { - await page.getByRole("button", { name: "Import" }).click(); - await expect(page.getByText("You’re all set!")).toBeVisible({ timeout }); + await openImportWallet({ page }); + await enterImportSecret({ page, secret }); + await fillPasswordAndConfirm({ page, password }); + await waitForImportSuccess({ page, timeout }); }; diff --git a/extension/e2e-tests/onboarding.test.ts b/extension/e2e-tests/onboarding.test.ts index 7c509313b3..e3f1cc02da 100644 --- a/extension/e2e-tests/onboarding.test.ts +++ b/extension/e2e-tests/onboarding.test.ts @@ -3,10 +3,12 @@ import StellarHDWallet from "stellar-hd-wallet"; import { test, expect, expectPageToHaveScreenshot } from "./test-fixtures"; import { loginToTestAccount, PASSWORD } from "./helpers/login"; import { - startImportWalletFlow, - fillMnemonicInputs, - clickImportAndWaitForSuccess, + openImportWallet, + enterImportSecret, + fillPasswordAndConfirm, + waitForImportSuccess, } from "./helpers/onboarding"; +import { stubAllExternalApis } from "./helpers/stubs"; import type { Page } from "@playwright/test"; import { goToSettings } from "./helpers/network"; @@ -96,39 +98,68 @@ test("Create new wallet", async ({ page }) => { }); }); -test("Import 12 word wallet", async ({ page }) => { - await page.getByText("Import wallet").click(); - await expect(page.getByText("Create a Password")).toBeVisible(); +const TEST_MNEMONIC_24_WORDS = [ + "shrug", + "absent", + "sausage", + "later", + "salute", + "mesh", + "increase", + "flavor", + "pilot", + "patch", + "pole", + "twenty", + "chef", + "coffee", + "faint", + "apology", + "crucial", + "scene", + "attend", + "replace", + "wolf", + "error", + "swift", + "device", +]; - await page.locator("#new-password-input").fill("My-password123"); - await page.locator("#confirm-password-input").fill("My-password123"); - await page.locator("#termsOfUse-input").check({ force: true }); - await page.getByText("Confirm").click(); +// Fixed testnet secret key, generated once for these tests. Unfunded; the +// tests only need an account to exist. +// GC73WAR74OMJ7VZPY6777H2HY7DHECD4Q3JAOREK255L2D2IPSDOMZJK +const TEST_SECRET_KEY = + "SBNBH3UWCW4WH5NIUSU624EVRZCQY4RWKDRDFSMJKBTVXHTFYIJP3RBA"; +const INVALID_INPUT_ERROR = "Enter a valid recovery phrase or secret key"; + +test("Import 12 word wallet", async ({ page }) => { + await openImportWallet({ page }); + await expect(page.getByText("Import Wallet", { exact: true })).toBeVisible(); await expect( - page.getByText("Import wallet from recovery phrase"), - ).toBeVisible(); - await expect( - page.getByText("You can also paste the phrase in the first box."), + page.getByText("Import your wallet using a recovery phrase or secret key"), ).toBeVisible(); + await expect(page.getByTestId("import-secret-next")).toBeDisabled(); - for (let i = 1; i <= TEST_MNEMONIC_12_WORDS.length; i++) { - await page - .locator(`#MnemonicPhrase-${i}`) - .fill(TEST_MNEMONIC_12_WORDS[i - 1]); - } + await page + .getByTestId("import-secret-input") + .fill(TEST_MNEMONIC_12_WORDS.join(" ")); await expectPageToHaveScreenshot( { page, screenshot: "wallet-import-12-word-phrase-page.png", }, - { mask: [page.locator(".RecoverAccount__mnemonic-input")] }, + { mask: [page.getByTestId("import-secret-input")] }, ); - await page.getByRole("button", { name: "Import" }).click(); + await page.getByTestId("import-secret-next").click(); + await fillPasswordAndConfirm({ page, password: PASSWORD }); - await expect(page.getByText("You’re all set!")).toBeVisible(); + await expect(page.getByText("Your wallet is ready!")).toBeVisible({ + timeout: 20000, + }); + await waitForImportSuccess({ page }); await expectPageToHaveScreenshot({ page, screenshot: "wallet-import-complete-page.png", @@ -138,98 +169,34 @@ test("Import 12 word wallet", async ({ page }) => { test("Import 12 word wallet by pasting", async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); - await page.getByText("Import wallet").click(); - await expect(page.getByText("Create a Password")).toBeVisible(); - - await page.locator("#new-password-input").fill("My-password123"); - await page.locator("#confirm-password-input").fill("My-password123"); - await page.locator("#termsOfUse-input").check({ force: true }); - await page.getByText("Confirm").click(); - - await expect( - page.getByText("Import wallet from recovery phrase"), - ).toBeVisible(); - await expect( - page.getByText("You can also paste the phrase in the first box."), - ).toBeVisible(); + await openImportWallet({ page }); await page.evaluate((argMnemonic) => { return navigator.clipboard.writeText(argMnemonic.join(" ")); }, TEST_MNEMONIC_12_WORDS); // paste text from clipboard - await page.locator("#MnemonicPhrase-1").press("ControlOrMeta+v"); - - await expectPageToHaveScreenshot( - { - page, - screenshot: "wallet-import-12-word-phrase-page.png", - }, - { mask: [page.locator(".RecoverAccount__mnemonic-input")] }, + await page.getByTestId("import-secret-input").focus(); + await page.getByTestId("import-secret-input").press("ControlOrMeta+v"); + await expect(page.getByTestId("import-secret-input")).toHaveValue( + TEST_MNEMONIC_12_WORDS.join(" "), ); // confirm the clipboard has been cleared - const clipboardData = await page.evaluate(() => - navigator.clipboard.readText(), - ); - expect(clipboardData).toBe(""); - - await page.getByRole("button", { name: "Import" }).click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(""); - await expect(page.getByText("You’re all set!")).toBeVisible(); - await expectPageToHaveScreenshot({ - page, - screenshot: "wallet-import-complete-page.png", - }); + await page.getByTestId("import-secret-next").click(); + await fillPasswordAndConfirm({ page, password: PASSWORD }); + await waitForImportSuccess({ page }); }); test("Import 24 word wallet", async ({ page }) => { - await page.getByText("Import wallet").click(); - await expect(page.getByText("Create a Password")).toBeVisible(); - - await page.locator("#new-password-input").fill("My-password123"); - await page.locator("#confirm-password-input").fill("My-password123"); - await page.locator("#termsOfUse-input").check({ force: true }); - await page.getByText("Confirm").click(); - - await expect( - page.getByText("Import wallet from recovery phrase"), - ).toBeVisible(); - await expect( - page.getByText("You can also paste the phrase in the first box."), - ).toBeVisible(); - await page.locator(".RecoverAccount__phrase-toggle > label").click(); - - const TEST_WORDS = [ - "shrug", - "absent", - "sausage", - "later", - "salute", - "mesh", - "increase", - "flavor", - "pilot", - "patch", - "pole", - "twenty", - "chef", - "coffee", - "faint", - "apology", - "crucial", - "scene", - "attend", - "replace", - "wolf", - "error", - "swift", - "device", - ]; - - for (let i = 1; i <= TEST_WORDS.length; i++) { - await page.locator(`#MnemonicPhrase-${i}`).fill(TEST_WORDS[i - 1]); - } + await openImportWallet({ page }); + await page + .getByTestId("import-secret-input") + .fill(TEST_MNEMONIC_24_WORDS.join(" ")); await expectPageToHaveScreenshot( { @@ -237,14 +204,12 @@ test("Import 24 word wallet", async ({ page }) => { screenshot: "wallet-import-24-word-phrase-page.png", threshold: 0.03, }, - { - mask: [page.locator(".RecoverAccount__mnemonic-wrapper")], - }, + { mask: [page.getByTestId("import-secret-input")] }, ); - await page.getByRole("button", { name: "Import" }).click(); - - await expect(page.getByText("You’re all set!")).toBeVisible(); + await page.getByTestId("import-secret-next").click(); + await fillPasswordAndConfirm({ page, password: PASSWORD }); + await waitForImportSuccess({ page }); await expectPageToHaveScreenshot({ page, screenshot: "wallet-import-complete-page.png", @@ -254,80 +219,82 @@ test("Import 24 word wallet", async ({ page }) => { test("Import 24 word wallet by pasting", async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]); - await page.getByText("Import wallet").click(); - await expect(page.getByText("Create a Password")).toBeVisible(); - - await page.locator("#new-password-input").fill("My-password123"); - await page.locator("#confirm-password-input").fill("My-password123"); - await page.locator("#termsOfUse-input").check({ force: true }); - await page.getByText("Confirm").click(); + await openImportWallet({ page }); - await expect( - page.getByText("Import wallet from recovery phrase"), - ).toBeVisible(); - await expect( - page.getByText("You can also paste the phrase in the first box."), - ).toBeVisible(); - - await page.evaluate(() => - navigator.clipboard.writeText( - [ - "shrug", - "absent", - "sausage", - "later", - "salute", - "mesh", - "increase", - "flavor", - "pilot", - "patch", - "pole", - "twenty", - "chef", - "coffee", - "faint", - "apology", - "crucial", - "scene", - "attend", - "replace", - "wolf", - "error", - "swift", - "device", - ].join(" "), - ), + await page.evaluate( + (argMnemonic) => navigator.clipboard.writeText(argMnemonic.join(" ")), + TEST_MNEMONIC_24_WORDS, ); // paste text from clipboard - await page.locator("#MnemonicPhrase-1").press("ControlOrMeta+v"); - - await expectPageToHaveScreenshot( - { - page, - screenshot: "wallet-import-12-word-phrase-page.png", - }, - { mask: [page.locator(".RecoverAccount__mnemonic-input")] }, + await page.getByTestId("import-secret-input").focus(); + await page.getByTestId("import-secret-input").press("ControlOrMeta+v"); + await expect(page.getByTestId("import-secret-input")).toHaveValue( + TEST_MNEMONIC_24_WORDS.join(" "), ); // confirm the clipboard has been cleared - const clipboardData = await page.evaluate(() => - navigator.clipboard.readText(), - ); - expect(clipboardData).toBe(""); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(""); - await page.getByRole("button", { name: "Import" }).click(); + await page.getByTestId("import-secret-next").click(); + await fillPasswordAndConfirm({ page, password: PASSWORD }); + await waitForImportSuccess({ page }); +}); - await expect(page.getByText("You’re all set!")).toBeVisible(); - await expectPageToHaveScreenshot({ - page, - screenshot: "wallet-import-complete-page.png", +test("onboards with a secret key", async ({ page, extensionId, context }) => { + test.slow(); + await stubAllExternalApis(page, context); + await openImportWallet({ page }); + await enterImportSecret({ page, secret: TEST_SECRET_KEY }); + await fillPasswordAndConfirm({ page, password: PASSWORD }); + + await expect(page.getByText("Your wallet is ready!")).toBeVisible({ + timeout: 20000, + }); + await waitForImportSuccess({ page }); + + await page.goto(`chrome-extension://${extensionId}/index.html#/`); + await expect(page.getByTestId("account-view")).toBeVisible({ + timeout: 30000, }); + + await page.getByTestId("account-view-account-name").click(); + await expect(page.getByTestId("wallet-row-select")).toHaveCount(1); + await page.getByTestId("AccountSheet__close").click(); + + await goToSettings(page); + await page.getByRole("link", { name: "Security" }).click(); + await expect( + page.getByRole("link", { name: "Advanced settings" }), + ).toBeVisible(); + await expect(page.getByText("Show recovery phrase")).toHaveCount(0); +}); + +test("The import text box masks its value until shown", async ({ page }) => { + await openImportWallet({ page }); + const input = page.getByTestId("import-secret-input"); + const visibility = page.getByTestId("import-secret-visibility"); + + await input.fill(TEST_SECRET_KEY); + await expect(input).toHaveClass(/RecoverAccount__input--masked/); + await expect(visibility).toHaveAttribute("aria-pressed", "false"); + + await visibility.click(); + await expect(input).not.toHaveClass(/RecoverAccount__input--masked/); + await expect(visibility).toHaveAttribute("aria-pressed", "true"); + + await visibility.click(); + await expect(input).toHaveClass(/RecoverAccount__input--masked/); }); test("Import wallet with wrong password", async ({ page }) => { - await page.getByText("Import wallet").click(); + await openImportWallet({ page }); + await enterImportSecret({ + page, + secret: TEST_MNEMONIC_12_WORDS.join(" "), + }); await expect(page.getByText("Create a password")).toBeVisible(); await page.locator("#new-password-input").fill("My-password123"); @@ -500,20 +467,11 @@ test("Reset wallet and import a wallet", async ({ const welcomePage = await resetWalletFromLockScreen(page, extensionId); - await welcomePage.getByText("Import wallet").click(); - await fillNewPassword(welcomePage); - await expect( - welcomePage.getByText("Import wallet from recovery phrase"), - ).toBeVisible(); - + await openImportWallet({ page: welcomePage }); const TEST_WORDS = generateMnemonic({ entropyBits: 128 }).split(" "); - for (let i = 1; i <= TEST_WORDS.length; i++) { - await welcomePage.locator(`#MnemonicPhrase-${i}`).fill(TEST_WORDS[i - 1]); - } - await welcomePage.getByRole("button", { name: "Import" }).click(); - await expect(welcomePage.getByText("You’re all set!")).toBeVisible({ - timeout: 20000, - }); + await enterImportSecret({ page: welcomePage, secret: TEST_WORDS.join(" ") }); + await fillNewPassword(welcomePage); + await waitForImportSuccess({ page: welcomePage }); await expectOnlyNewWalletAccount(welcomePage, extensionId); await unlockOriginalPage(page); @@ -707,139 +665,87 @@ test("Password input is auto focused on Show recovery phrase screen", async ({ await expect(page.locator("#password")).toBeFocused(); }); -// Password preservation tests -test("Wrong mnemonic phrase preserves previous state (pw + ToS) and allows retry", async ({ - page, - extensionId, -}) => { - test.slow(); - await page.goto(`chrome-extension://${extensionId}/index.html`); - - const PASSWORD_TEST = "My-password123"; - await startImportWalletFlow({ page, password: PASSWORD_TEST }); - - const wrongWords = Array(12).fill("invalid"); - await fillMnemonicInputs({ page, words: wrongWords }); - - await page.getByRole("button", { name: "Import" }).click(); - await expect(page.getByText("Invalid mnemonic phrase")).toBeVisible({ - timeout: 5000, - }); - - const firstMnemonicAfterError = await page - .locator('input[name="MnemonicPhrase-1"]') - .inputValue(); - expect(firstMnemonicAfterError).toBe(""); - - await fillMnemonicInputs({ page, words: TEST_MNEMONIC_12_WORDS }); - - await clickImportAndWaitForSuccess({ page }); -}); - -test("Wrong mnemonic phrase clears mnemonic inputs but preserves pw + ToS from previous page", async ({ +// Input validation and retry +test("Invalid recovery phrase shows an error and allows retry", async ({ page, - extensionId, }) => { test.slow(); - await page.goto(`chrome-extension://${extensionId}/index.html`); - - const PASSWORD_TEST = "SecurePass456"; - await startImportWalletFlow({ page, password: PASSWORD_TEST }); + await openImportWallet({ page }); - const wrongWords = Array(TEST_MNEMONIC_12_WORDS.length).fill("wrong"); - await fillMnemonicInputs({ page, words: wrongWords }); - - await page.getByRole("button", { name: "Import" }).click(); - await expect(page.getByText("Invalid mnemonic phrase")).toBeVisible({ - timeout: 5000, + await enterImportSecret({ + page, + secret: Array(12).fill("invalid").join(" "), }); + await expect(page.getByText(INVALID_INPUT_ERROR)).toBeVisible(); + // Next does not open the password step for invalid input. + await expect(page.locator("#new-password-input")).toHaveCount(0); - const firstMnemonicAfterError = await page - .locator('input[name="MnemonicPhrase-1"]') - .inputValue(); - expect(firstMnemonicAfterError).toBe(""); + // Editing the value clears the error. + await page + .getByTestId("import-secret-input") + .fill(TEST_MNEMONIC_12_WORDS.join(" ")); + await expect(page.getByText(INVALID_INPUT_ERROR)).toHaveCount(0); - await fillMnemonicInputs({ page, words: TEST_MNEMONIC_12_WORDS }); - await clickImportAndWaitForSuccess({ page }); + await page.getByTestId("import-secret-next").click(); + await fillPasswordAndConfirm({ page, password: "SecurePass456" }); + await waitForImportSuccess({ page }); }); -test("Switch mnemonic phrase length preserves previous state (pw + ToS)", async ({ - page, - extensionId, -}) => { - test.slow(); - await page.goto(`chrome-extension://${extensionId}/index.html`); - - const PASSWORD_TEST = "AnotherPass789"; - await startImportWalletFlow({ page, password: PASSWORD_TEST }); - - const toggleLabel = page.locator('label[for="RecoverAccount__toggle"]'); - const input12 = page.locator('input[name="MnemonicPhrase-12"]'); - const input13 = page.locator('input[name="MnemonicPhrase-13"]'); - - await expect(toggleLabel).toBeVisible(); - await expect(input12).toBeVisible(); - await expect(input13).toBeHidden(); - - await toggleLabel.click(); - await expect(input13).toBeVisible(); +test("Invalid secret key shows an error", async ({ page }) => { + await openImportWallet({ page }); - await toggleLabel.click(); - await expect(input13).toBeHidden(); - await expect(input12).toBeVisible(); - - await fillMnemonicInputs({ page, words: TEST_MNEMONIC_12_WORDS }); - - await clickImportAndWaitForSuccess({ page }); + // A secret key with its last character changed fails the checksum. + const badSecret = `${TEST_SECRET_KEY.slice(0, -1)}${ + TEST_SECRET_KEY.endsWith("A") ? "B" : "A" + }`; + await enterImportSecret({ page, secret: badSecret }); + await expect(page.getByText(INVALID_INPUT_ERROR)).toBeVisible(); + await expect(page.locator("#new-password-input")).toHaveCount(0); }); -test("Enter wrong mnemonic multiple times and retry preserves previous state (pw + ToS) and allows successful import", async ({ +test("Multiple invalid attempts then a valid phrase imports", async ({ page, - extensionId, }) => { test.slow(); - await page.goto(`chrome-extension://${extensionId}/index.html`); - - const PASSWORD_TEST = "PasteTestPass123"; - await startImportWalletFlow({ page, password: PASSWORD_TEST }); + await openImportWallet({ page }); - const wrongWords = Array(12).fill("wrong"); - await fillMnemonicInputs({ page, words: wrongWords }); - - await page.getByRole("button", { name: "Import" }).click(); - await expect(page.getByText("Invalid mnemonic phrase")).toBeVisible({ - timeout: 5000, + await enterImportSecret({ + page, + secret: Array(12).fill("attempt1").join(" "), }); + await expect(page.getByText(INVALID_INPUT_ERROR)).toBeVisible(); - await fillMnemonicInputs({ page, words: TEST_MNEMONIC_12_WORDS }); + await enterImportSecret({ + page, + secret: Array(24).fill("attempt2").join(" "), + }); + await expect(page.getByText(INVALID_INPUT_ERROR)).toBeVisible(); - await clickImportAndWaitForSuccess({ page }); + await enterImportSecret({ + page, + secret: TEST_MNEMONIC_12_WORDS.join(" "), + }); + await fillPasswordAndConfirm({ page, password: "MultiRetryPass999" }); + await waitForImportSuccess({ page }); }); -test("Multiple failed attempts preserve state across retries (pw + ToS)", async ({ +test("Back from the password step keeps the entered value", async ({ page, - extensionId, }) => { test.slow(); - await page.goto(`chrome-extension://${extensionId}/index.html`); - - const PASSWORD_TEST = "MultiRetryPass999"; - await startImportWalletFlow({ page, password: PASSWORD_TEST }); - - const attempt1Words = Array(TEST_MNEMONIC_12_WORDS.length).fill("attempt1"); - await fillMnemonicInputs({ page, words: attempt1Words }); - await page.getByRole("button", { name: "Import" }).click(); - await expect(page.getByText("Invalid mnemonic phrase")).toBeVisible({ - timeout: 5000, + await openImportWallet({ page }); + await enterImportSecret({ + page, + secret: TEST_MNEMONIC_12_WORDS.join(" "), }); + await expect(page.locator("#new-password-input")).toBeVisible(); - const attempt2Words = Array(TEST_MNEMONIC_12_WORDS.length).fill("attempt2"); - await fillMnemonicInputs({ page, words: attempt2Words }); - await page.getByRole("button", { name: "Import" }).click(); - await expect(page.getByText("Invalid mnemonic phrase")).toBeVisible({ - timeout: 5000, - }); + await page.getByRole("button", { name: "Back" }).click(); + await expect(page.getByTestId("import-secret-input")).toHaveValue( + TEST_MNEMONIC_12_WORDS.join(" "), + ); - await fillMnemonicInputs({ page, words: TEST_MNEMONIC_12_WORDS }); - await clickImportAndWaitForSuccess({ page }); + await page.getByTestId("import-secret-next").click(); + await fillPasswordAndConfirm({ page, password: "AnotherPass789" }); + await waitForImportSuccess({ page }); }); diff --git a/extension/e2e-tests/onboarding.test.ts-snapshots/wallet-import-12-word-phrase-page-chromium-darwin.png b/extension/e2e-tests/onboarding.test.ts-snapshots/wallet-import-12-word-phrase-page-chromium-darwin.png index b4681d911ef5d3df4e43fa30e686381b1fd44b0d..a0f6ac0eb75f9ba645544991d181f49b47e3dbd0 100644 GIT binary patch literal 15581 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 literal 23709 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 diff --git a/extension/e2e-tests/onboarding.test.ts-snapshots/wallet-import-24-word-phrase-page-chromium-darwin.png b/extension/e2e-tests/onboarding.test.ts-snapshots/wallet-import-24-word-phrase-page-chromium-darwin.png index 27bf673e7acfdfea0e47375e07a19374f890c1b1..a0f6ac0eb75f9ba645544991d181f49b47e3dbd0 100644 GIT binary patch literal 15581 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 literal 24138 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 diff --git a/extension/e2e-tests/translations-pt.test.ts b/extension/e2e-tests/translations-pt.test.ts index 342aa092f2..2e9198045b 100644 --- a/extension/e2e-tests/translations-pt.test.ts +++ b/extension/e2e-tests/translations-pt.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "./test-fixtures"; import { loginToTestAccountPT } from "./helpers/login-pt"; +import { TEST_ACCOUNT_WORDS } from "./helpers/login"; import { stubAllExternalApis } from "./helpers/stubs"; test.use({ language: "pt" }); @@ -24,19 +25,25 @@ test("Smoke test: Portuguese translations in account view", async ({ // Login first await page.goto(`chrome-extension://${extensionId}/index.html`); await page.getByText("Importar carteira").click(); - await expect(page.getByText("Criar uma Senha")).toBeVisible(); - - // Fill password form with Portuguese labels - await page.locator("#new-password-input").fill("My-password123"); - await page.locator("#confirm-password-input").fill("My-password123"); - await page.locator("#termsOfUse-input").check({ force: true }); - await expect(page.getByText("Confirmar")).toBeVisible(); - await page.getByText("Confirmar").click(); // Verify Portuguese text in import flow await expect( - page.getByText("Importar carteira da frase de recuperação"), + page.getByText( + "Importe sua carteira usando uma frase de recuperação ou chave secreta", + ), + ).toBeVisible(); + await expect( + page.getByPlaceholder("Insira sua frase de recuperação ou chave secreta"), ).toBeVisible(); + + await page + .getByTestId("import-secret-input") + .fill(TEST_ACCOUNT_WORDS.join(" ")); + await page.getByTestId("import-secret-next").click(); + + // Password form with Portuguese labels + await expect(page.getByText("Criar uma Senha")).toBeVisible(); + await expect(page.getByText("Confirmar")).toBeVisible(); }); test("Smoke test: Portuguese translations in settings", async ({ diff --git a/extension/src/background/helpers/account-mutation-lock.ts b/extension/src/background/helpers/account-mutation-lock.ts index db3b880828..2f329590ab 100644 --- a/extension/src/background/helpers/account-mutation-lock.ts +++ b/extension/src/background/helpers/account-mutation-lock.ts @@ -18,6 +18,7 @@ import { SERVICE_TYPES } from "@shared/constants/services"; export const ACCOUNT_MUTATING_SERVICE_TYPES: ReadonlySet = new Set([ SERVICE_TYPES.CREATE_ACCOUNT, SERVICE_TYPES.RECOVER_ACCOUNT, + SERVICE_TYPES.RECOVER_ACCOUNT_FROM_SECRET_KEY, SERVICE_TYPES.ADD_ACCOUNT, SERVICE_TYPES.IMPORT_ACCOUNT, SERVICE_TYPES.IMPORT_HARDWARE_WALLET, diff --git a/extension/src/background/messageListener/__tests__/recoverAccountFromSecretKey.test.ts b/extension/src/background/messageListener/__tests__/recoverAccountFromSecretKey.test.ts new file mode 100644 index 0000000000..3659f07b62 --- /dev/null +++ b/extension/src/background/messageListener/__tests__/recoverAccountFromSecretKey.test.ts @@ -0,0 +1,244 @@ +import { Keypair } from "stellar-sdk"; + +import { sessionSlice } from "background/ducks/session"; +import { popupMessageListener } from "background/messageListener/popupMessageListener"; +import { SERVICE_TYPES } from "@shared/constants/services"; +import { + RecoverAccountFromSecretKeyMessage, + RecoverAccountMessage, +} from "@shared/api/types/message-request"; +import { + mockDataStorage, + mockSessionStore, + mockKeyManager, + mockStorageApi, + MockBrowserAlarm, +} from "background/messageListener/helpers/test-helpers"; +import { AUTH_KEYPAIR_VECTORS } from "@shared/api/helpers/authKeypairVectors"; +import { SecretSourceType } from "@shared/api/types"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import { + APPLICATION_ID, + KEY_ID, + KEY_ID_LIST, +} from "constants/localStorageTypes"; +import { + addSecretSource, + getSecretKeySourceId, + getSecretSources, +} from "background/helpers/secret-sources"; +import { getDerivationIndices } from "background/helpers/derivation-indices"; +import { ACCOUNT_MUTATING_SERVICE_TYPES } from "background/helpers/account-mutation-lock"; + +jest.mock("background/helpers/secret-sources", () => ({ + ...jest.requireActual("background/helpers/secret-sources"), + addSecretSource: jest.fn(), +})); + +// The linker is fire-and-forget: a real pass outlives the test that started it +// and writes into the next test's storage. Only the trigger matters here. +jest.mock("background/helpers/link-sources", () => ({ + linkWalletSources: jest.fn().mockResolvedValue(undefined), +})); + +// The phrase seed only needs to store an account; as in recoverAccount.test, +// the real HD wallet is swapped for a stub that derives per-index keys. +jest.mock("stellar-hd-wallet", () => ({ + generateMnemonic: () => "unused", + fromMnemonic: () => ({ + getPublicKey: (index: number) => `pubKey${index}`, + getSecret: (index: number) => `secret${index}`, + }), +})); + +const VECTOR = AUTH_KEYPAIR_VECTORS[0]; +const SECRET = Keypair.random().secret(); + +const testAlarm = new MockBrowserAlarm(() => {}); + +type RecoveryResponse = { + allAccounts: { publicKey: string; imported: boolean; id: string }[]; + publicKey: string; + applicationState: string; + hasPrivateKey: boolean; + error: string; +}; + +const send = (message: object) => + popupMessageListener( + message as any, + mockSessionStore, + mockDataStorage, + mockKeyManager, + testAlarm as any, + { id: "fake-extension-id" }, + ) as Promise; + +const recoverFromSecretKey = ( + secretKey: string, + isOverwritingAccount = false, +) => + send({ + type: SERVICE_TYPES.RECOVER_ACCOUNT_FROM_SECRET_KEY, + password: "test", + secretKey, + isOverwritingAccount, + } as RecoverAccountFromSecretKeyMessage); + +const recoverFromPhrase = () => + send({ + type: SERVICE_TYPES.RECOVER_ACCOUNT, + password: "test", + recoverMnemonic: VECTOR.mnemonic, + isOverwritingAccount: false, + } as RecoverAccountMessage); + +const snapshotWallet = async () => { + const keyIdList = (await mockDataStorage.getItem(KEY_ID_LIST)) as string[]; + const keystores = await Promise.all( + keyIdList.map((id) => mockDataStorage.getItem(`stellarkeys:${id}`)), + ); + return { + keyIdList, + keystores, + sources: await getSecretSources({ localStore: mockDataStorage }), + applicationState: await mockDataStorage.getItem(APPLICATION_ID), + }; +}; + +describe("Recover account from secret key message listener", () => { + // Each recovery runs Scrypt; under a parallel run that outlasts the 5 s + // default, as the sibling suites already allow for. + jest.setTimeout(60_000); + + let startSession: jest.SpyInstance; + + beforeEach(async () => { + await mockStorageApi.clear(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + // Nothing past index 0 is funded, so seeding a phrase wallet stores one + // account and stays fast. + global.fetch = jest.fn(() => + Promise.resolve({ json: () => Promise.resolve({}) }), + ) as unknown as typeof fetch; + startSession = jest.spyOn(testAlarm, "startSession"); + const actual = jest.requireActual("background/helpers/secret-sources"); + (addSecretSource as jest.Mock).mockImplementation(actual.addSecretSource); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("creates a one-account wallet with no phrase", async () => { + const res = await recoverFromSecretKey(SECRET); + + expect(res.error).toBe(""); + expect(res.allAccounts).toHaveLength(1); + expect(res.allAccounts[0].imported).toBe(true); + expect(res.publicKey).toBe(Keypair.fromSecret(SECRET).publicKey()); + expect(res.hasPrivateKey).toBe(true); + + const sourceId = await getSecretKeySourceId(SECRET); + const sources = await getSecretSources({ localStore: mockDataStorage }); + expect(Object.values(sources)).toEqual([ + expect.objectContaining({ + id: sourceId, + type: SecretSourceType.SECRET_KEY, + }), + ]); + + const key = await mockKeyManager.loadKey(res.allAccounts[0].id, "test"); + expect(key.extra).not.toHaveProperty("mnemonicPhrase"); + + expect(await mockDataStorage.getItem(APPLICATION_ID)).toBe( + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + }); + + it("rejects an invalid key without touching storage", async () => { + await recoverFromPhrase(); + const before = await snapshotWallet(); + expect(before.keyIdList).toHaveLength(1); + + for (const isOverwritingAccount of [false, true]) { + const res = await recoverFromSecretKey("SBAD", isOverwritingAccount); + + expect(res.error).toBe("Invalid secret key"); + expect(await snapshotWallet()).toEqual(before); + } + }); + + it("replaces an existing wallet when overwriting", async () => { + await recoverFromPhrase(); + expect( + await getDerivationIndices({ + localStore: mockDataStorage, + sourceId: VECTOR.userId, + }), + ).toEqual([0]); + + const res = await recoverFromSecretKey(SECRET, true); + + expect(res.error).toBe(""); + expect(res.allAccounts.map(({ publicKey }) => publicKey)).toEqual([ + Keypair.fromSecret(SECRET).publicKey(), + ]); + expect( + (await mockDataStorage.getItem(KEY_ID_LIST)) as string[], + ).toHaveLength(1); + const sourceId = await getSecretKeySourceId(SECRET); + expect( + Object.keys(await getSecretSources({ localStore: mockDataStorage })), + ).toEqual([sourceId]); + expect( + await getDerivationIndices({ + localStore: mockDataStorage, + sourceId: VECTOR.userId, + }), + ).toEqual([]); + }); + + it("leaves a clean fresh install when storing fails", async () => { + jest + .spyOn(mockKeyManager, "storeKey") + .mockRejectedValueOnce(new Error("storage full")); + + const res = await recoverFromSecretKey(SECRET); + + expect(res.error).toBe("Error importing account"); + expect( + ((await mockDataStorage.getItem(KEY_ID_LIST)) as string[] | null) ?? [], + ).toEqual([]); + expect(await getSecretSources({ localStore: mockDataStorage })).toEqual({}); + expect(await mockDataStorage.getItem(APPLICATION_ID)).toBeFalsy(); + expect(startSession).not.toHaveBeenCalled(); + }); + + it("leaves a clean fresh install when registering the source fails", async () => { + (addSecretSource as jest.Mock).mockRejectedValueOnce(new Error("boom")); + + const res = await recoverFromSecretKey(SECRET); + + expect(res.error).toBe("Error importing account"); + const keyIdList = + ((await mockDataStorage.getItem(KEY_ID_LIST)) as string[] | null) ?? []; + expect(keyIdList).toEqual([]); + expect(await mockDataStorage.getItem(KEY_ID)).toBeFalsy(); + const leftover = Object.keys(await mockStorageApi.get(null)).filter((k) => + k.startsWith("stellarkeys:"), + ); + expect(leftover).toEqual([]); + expect(await getSecretSources({ localStore: mockDataStorage })).toEqual({}); + expect(await mockDataStorage.getItem(APPLICATION_ID)).toBeFalsy(); + expect(startSession).not.toHaveBeenCalled(); + }); + + it("is serialized with other account mutations", () => { + expect( + ACCOUNT_MUTATING_SERVICE_TYPES.has( + SERVICE_TYPES.RECOVER_ACCOUNT_FROM_SECRET_KEY, + ), + ).toBe(true); + }); +}); diff --git a/extension/src/background/messageListener/handlers/recoverAccount.ts b/extension/src/background/messageListener/handlers/recoverAccount.ts index bdc9884d39..7b310b5b98 100644 --- a/extension/src/background/messageListener/handlers/recoverAccount.ts +++ b/extension/src/background/messageListener/handlers/recoverAccount.ts @@ -4,39 +4,21 @@ import StellarHDWallet from "stellar-hd-wallet"; import { RecoverAccountMessage } from "@shared/api/types/message-request"; import { removePreviousAccount } from "../helpers/remove-previous-account"; import { DataStorageAccess } from "background/helpers/dataStorageAccess"; -import { - allAccountsSelector, - buildHasPrivateKeySelector, - publicKeySelector, - reset, -} from "background/ducks/session"; -import { getKeyIdList, getActiveAccountId } from "background/helpers/account"; -import { - ACCOUNT_NAME_LIST_ID, - APPLICATION_ID, - REBRAND_ANNOUNCEMENT_STATUS_ID, - KEY_ID_LIST, -} from "constants/localStorageTypes"; -import { REBRAND_ANNOUNCEMENT_STATUS } from "constants/rebrandAnnouncement"; -import { clearSession, SessionTimer } from "background/helpers/session"; +import { SessionTimer } from "background/helpers/session"; import { storeAccount } from "../helpers/store-account"; import { KeyManager } from "@stellar/typescript-wallet-sdk-km"; import { captureException } from "@sentry/browser"; -import { APPLICATION_STATE } from "@shared/constants/applicationState"; import { discoverFundedIndices } from "../helpers/discover-funded-indices"; -import { activatePublicKey } from "../helpers/activate-public-key"; import { addSecretSource, - clearPinnedSourceId, - clearSecretSources, getPhraseSourceId, } from "background/helpers/secret-sources"; import { SecretSourceType } from "@shared/api/types"; -import { clearDerivationIndices } from "background/helpers/derivation-indices"; -import { clearAccountProvenance } from "background/helpers/account-provenance"; -import { clearPendingAttribution } from "background/helpers/pre-v2-migration"; -import { clearLinkStatus } from "background/helpers/link-status"; -import { linkWalletSources } from "background/helpers/link-sources"; +import { + buildRecoveryResponse, + clearWalletForRecovery, + finishRecovery, +} from "../helpers/recovery-lifecycle"; const { fromMnemonic } = StellarHDWallet; @@ -58,7 +40,6 @@ export const recoverAccount = async ({ const { password, recoverMnemonic, isOverwritingAccount } = request; let wallet; let phraseSourceId = ""; - let applicationState; let error = ""; if (isOverwritingAccount) { @@ -80,42 +61,7 @@ export const recoverAccount = async ({ privateKey: wallet.getSecret(0), }; - // resets accounts list - sessionStore.dispatch(reset()); - - const keyIdList = await getKeyIdList({ localStore }); - - if (keyIdList.length) { - /* Clear any existing account data while maintaining app settings */ - - for (let i = 0; i < keyIdList.length; i += 1) { - await localStore.remove(`stellarkeys:${keyIdList[i]}`); - } - - await localStore.setItem(KEY_ID_LIST, []); - await localStore.remove(ACCOUNT_NAME_LIST_ID); - await clearSecretSources({ localStore }); - await clearPinnedSourceId({ localStore }); - } - - // A recovery always starts the wallet afresh, so every phrase's index - // set, every account's provenance, any accounts the pre-V2 migration - // left pending and every source's link status go with it, whether or not - // the list above had anything in it; the recovered phrase rebuilds its - // own below. - await clearDerivationIndices({ localStore }); - await clearAccountProvenance({ localStore }); - await clearPendingAttribution({ localStore }); - await clearLinkStatus({ localStore }); - - // Recovery is onboarding (also the "forgot password" path), which must - // not be followed by the one-time rebrand announcement. - await localStore.setItem( - REBRAND_ANNOUNCEMENT_STATUS_ID, - REBRAND_ANNOUNCEMENT_STATUS.SEEN, - ); - - await clearSession({ localStore, sessionStore }); + await clearWalletForRecovery({ localStore, sessionStore }); try { await storeAccount({ @@ -139,13 +85,6 @@ export const recoverAccount = async ({ captureException(`Error recovering account: ${JSON.stringify(e)}`); } - // if we don't have an application state, assign them one - applicationState = - (await localStore.getItem(APPLICATION_ID)) || - APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED; - - await localStore.setItem(APPLICATION_ID, applicationState); - // lets check first couple of accounts and pre-load them if funded on mainnet const fundedIndices = await discoverFundedIndices({ @@ -186,30 +125,13 @@ export const recoverAccount = async ({ } // let's make the first public key the active one - await activatePublicKey({ + await finishRecovery({ publicKey: wallet.getPublicKey(0), - sessionStore, localStore, + sessionStore, + sessionTimer, }); - - // start the timer now that we have active private key - await sessionTimer.startSession(); - - // Link the recovered phrase and the accounts preloaded above. This runs - // on every recover rather than keying off the application-state write, - // which a re-import repeats; the stored link status keeps it idempotent. - void linkWalletSources({ sessionStore, localStore }); } - const currentState = sessionStore.getState(); - const hasPrivateKeySelector = buildHasPrivateKeySelector(localStore); - - return { - allAccounts: allAccountsSelector(currentState), - publicKey: publicKeySelector(currentState), - activeAccountId: await getActiveAccountId({ localStore }), - applicationState: (await localStore.getItem(APPLICATION_ID)) || "", - hasPrivateKey: await hasPrivateKeySelector(currentState), - error, - }; + return buildRecoveryResponse({ localStore, sessionStore, error }); }; diff --git a/extension/src/background/messageListener/handlers/recoverAccountFromSecretKey.ts b/extension/src/background/messageListener/handlers/recoverAccountFromSecretKey.ts new file mode 100644 index 0000000000..3402120ea1 --- /dev/null +++ b/extension/src/background/messageListener/handlers/recoverAccountFromSecretKey.ts @@ -0,0 +1,106 @@ +import { Store } from "redux"; +import { Keypair } from "stellar-sdk"; +import { KeyManager } from "@stellar/typescript-wallet-sdk-km"; +import { captureException } from "@sentry/browser"; + +import { RecoverAccountFromSecretKeyMessage } from "@shared/api/types/message-request"; +import { SecretSourceType } from "@shared/api/types"; +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { SessionTimer } from "background/helpers/session"; +import { + addSecretSource, + getSecretKeySourceId, +} from "background/helpers/secret-sources"; +import { APPLICATION_ID, KEY_ID } from "constants/localStorageTypes"; +import { removePreviousAccount } from "../helpers/remove-previous-account"; +import { storeAccount } from "../helpers/store-account"; +import { + buildRecoveryResponse, + clearWalletForRecovery, + finishRecovery, +} from "../helpers/recovery-lifecycle"; + +/* + Onboards a wallet from a single secret key: one imported account rooted in + its own SECRET_KEY source, with no phrase. Unlike the phrase handler it + validates before anything is removed, and a failed store leaves a clean + fresh install rather than a half-written wallet. +*/ +export const recoverAccountFromSecretKey = async ({ + request, + localStore, + sessionStore, + keyManager, + sessionTimer, +}: { + request: RecoverAccountFromSecretKeyMessage; + localStore: DataStorageAccess; + sessionStore: Store; + keyManager: KeyManager; + sessionTimer: SessionTimer; +}) => { + const { password, secretKey, isOverwritingAccount } = request; + let keyPair; + let sourceId; + + // Nothing is logged here: the error would carry the key. + try { + keyPair = { + publicKey: Keypair.fromSecret(secretKey).publicKey(), + privateKey: secretKey, + }; + sourceId = await getSecretKeySourceId(secretKey); + } catch { + return buildRecoveryResponse({ + localStore, + sessionStore, + error: "Invalid secret key", + }); + } + + if (isOverwritingAccount) { + await removePreviousAccount({ localStore }); + } + + await clearWalletForRecovery({ localStore, sessionStore }); + + try { + await storeAccount({ + password, + keyPair, + sourceId, + imported: true, + isSettingHashKey: true, + sessionStore, + localStore, + keyManager, + }); + await addSecretSource({ + localStore, + id: sourceId, + type: SecretSourceType.SECRET_KEY, + }); + } catch { + // No payload: a thrown error may carry the key. + captureException("Error recovering account from secret key"); + // The previous wallet is already gone, so undo the partial writes and + // leave a fresh install the user can retry from. + await clearWalletForRecovery({ localStore, sessionStore }); + await localStore.remove(APPLICATION_ID); + await localStore.remove(KEY_ID); + return buildRecoveryResponse({ + localStore, + sessionStore, + error: "Error importing account", + }); + } + + await finishRecovery({ + publicKey: keyPair.publicKey, + localStore, + sessionStore, + sessionTimer, + }); + + return buildRecoveryResponse({ localStore, sessionStore, error: "" }); +}; diff --git a/extension/src/background/messageListener/helpers/recovery-lifecycle.ts b/extension/src/background/messageListener/helpers/recovery-lifecycle.ts new file mode 100644 index 0000000000..c0e1e7b4ac --- /dev/null +++ b/extension/src/background/messageListener/helpers/recovery-lifecycle.ts @@ -0,0 +1,140 @@ +import { Store } from "redux"; + +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { + allAccountsSelector, + buildHasPrivateKeySelector, + publicKeySelector, + reset, +} from "background/ducks/session"; +import { getKeyIdList, getActiveAccountId } from "background/helpers/account"; +import { + ACCOUNT_NAME_LIST_ID, + APPLICATION_ID, + REBRAND_ANNOUNCEMENT_STATUS_ID, + KEY_ID_LIST, +} from "constants/localStorageTypes"; +import { REBRAND_ANNOUNCEMENT_STATUS } from "constants/rebrandAnnouncement"; +import { clearSession, SessionTimer } from "background/helpers/session"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import { + clearPinnedSourceId, + clearSecretSources, +} from "background/helpers/secret-sources"; +import { clearDerivationIndices } from "background/helpers/derivation-indices"; +import { clearAccountProvenance } from "background/helpers/account-provenance"; +import { clearPendingAttribution } from "background/helpers/pre-v2-migration"; +import { clearLinkStatus } from "background/helpers/link-status"; +import { linkWalletSources } from "background/helpers/link-sources"; +import { activatePublicKey } from "./activate-public-key"; + +/* + Wipes the wallet's accounts and sources ahead of a recovery while keeping + app settings. Both recovery handlers call it after validating their input + (and after removePreviousAccount when overwriting). +*/ +export const clearWalletForRecovery = async ({ + localStore, + sessionStore, +}: { + localStore: DataStorageAccess; + sessionStore: Store; +}) => { + // resets accounts list + sessionStore.dispatch(reset()); + + const keyIdList = await getKeyIdList({ localStore }); + + if (keyIdList.length) { + /* Clear any existing account data while maintaining app settings */ + + for (let i = 0; i < keyIdList.length; i += 1) { + await localStore.remove(`stellarkeys:${keyIdList[i]}`); + } + + await localStore.setItem(KEY_ID_LIST, []); + await localStore.remove(ACCOUNT_NAME_LIST_ID); + await clearSecretSources({ localStore }); + await clearPinnedSourceId({ localStore }); + } + + // A recovery always starts the wallet afresh, so every phrase's index + // set, every account's provenance, any accounts the pre-V2 migration + // left pending and every source's link status go with it, whether or not + // the list above had anything in it; the recovered source rebuilds its + // own afterwards. + await clearDerivationIndices({ localStore }); + await clearAccountProvenance({ localStore }); + await clearPendingAttribution({ localStore }); + await clearLinkStatus({ localStore }); + + // Recovery is onboarding (also the "forgot password" path), which must + // not be followed by the one-time rebrand announcement. + await localStore.setItem( + REBRAND_ANNOUNCEMENT_STATUS_ID, + REBRAND_ANNOUNCEMENT_STATUS.SEEN, + ); + + await clearSession({ localStore, sessionStore }); +}; + +/* + Makes a recovered wallet usable: gives it an application state if it has + none, activates `publicKey`, starts the session timer and links the + wallet's sources. +*/ +export const finishRecovery = async ({ + publicKey, + localStore, + sessionStore, + sessionTimer, +}: { + publicKey: string; + localStore: DataStorageAccess; + sessionStore: Store; + sessionTimer: SessionTimer; +}) => { + // if we don't have an application state, assign them one + const applicationState = + (await localStore.getItem(APPLICATION_ID)) || + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED; + + await localStore.setItem(APPLICATION_ID, applicationState); + + await activatePublicKey({ + publicKey, + sessionStore, + localStore, + }); + + // start the timer now that we have active private key + await sessionTimer.startSession(); + + // Link the recovered sources. This runs on every recover rather than + // keying off the application-state write, which a re-import repeats; the + // stored link status keeps it idempotent. + void linkWalletSources({ sessionStore, localStore }); +}; + +/* The response both recovery handlers return. */ +export const buildRecoveryResponse = async ({ + localStore, + sessionStore, + error, +}: { + localStore: DataStorageAccess; + sessionStore: Store; + error: string; +}) => { + const currentState = sessionStore.getState(); + const hasPrivateKeySelector = buildHasPrivateKeySelector(localStore); + + return { + allAccounts: allAccountsSelector(currentState), + publicKey: publicKeySelector(currentState), + activeAccountId: await getActiveAccountId({ localStore }), + applicationState: (await localStore.getItem(APPLICATION_ID)) || "", + hasPrivateKey: await hasPrivateKeySelector(currentState), + error, + }; +}; diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index b3ea69c0fc..7749e1f103 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -51,6 +51,7 @@ import { getMnemonicPhrase } from "./handlers/getMnemonicPhrase"; import { confirmMnemonicPhrase } from "./handlers/confirmMnemonicPhrase"; import { confirmMigratedMnemonicPhrase } from "./handlers/confirmMigratedMnemonicPhrase"; import { recoverAccount } from "./handlers/recoverAccount"; +import { recoverAccountFromSecretKey } from "./handlers/recoverAccountFromSecretKey"; import { showBackupPhrase } from "./handlers/showBackupPhrase"; import { revealSecretKey } from "./handlers/revealSecretKey"; import { confirmPassword } from "./handlers/confirmPassword"; @@ -359,6 +360,15 @@ export const popupMessageListener = ( numOfPublicKeysToCheck, }); } + case SERVICE_TYPES.RECOVER_ACCOUNT_FROM_SECRET_KEY: { + return recoverAccountFromSecretKey({ + request, + sessionStore, + localStore, + keyManager, + sessionTimer, + }); + } case SERVICE_TYPES.SHOW_BACKUP_PHRASE: { // The recovery phrase leaves the background only for our own pages // (and the dev-server popup, which relays through the content script). diff --git a/extension/src/popup/ducks/accountServices.ts b/extension/src/popup/ducks/accountServices.ts index 575fdac397..8d43117414 100644 --- a/extension/src/popup/ducks/accountServices.ts +++ b/extension/src/popup/ducks/accountServices.ts @@ -21,6 +21,7 @@ import { createAccount as createAccountService, fundAccount as fundAccountService, recoverAccount as recoverAccountService, + recoverAccountFromSecretKey as recoverAccountFromSecretKeyService, confirmPassword as confirmPasswordService, signOut as signOutService, resetWallet as resetWalletService, @@ -373,6 +374,55 @@ export const recoverAccount = createAsyncThunk< }, ); +export const recoverAccountFromSecretKey = createAsyncThunk< + { + allAccounts: Account[]; + hasPrivateKey: boolean; + publicKey: string; + activeAccountId: string; + error: string; + }, + { + password: string; + secretKey: string; + isOverwritingAccount?: boolean; + }, + { rejectValue: ErrorMessage } +>( + "auth/recoverAccountFromSecretKey", + async ({ password, secretKey, isOverwritingAccount = false }, thunkApi) => { + let res = { + allAccounts: [] as Account[], + publicKey: "", + activeAccountId: "", + hasPrivateKey: false, + error: "", + }; + + try { + res = await recoverAccountFromSecretKeyService({ + password, + secretKey, + isOverwritingAccount, + }); + } catch (e) { + // Log a fixed string only; never the secret key. + console.error("Failed when importing an account from a secret key"); + return thunkApi.rejectWithValue({ + errorMessage: "Error importing account", + }); + } + + if (!res.publicKey || res.error) { + return thunkApi.rejectWithValue({ + errorMessage: res.error || "Error importing account", + }); + } + + return res; + }, +); + export const confirmMnemonicPhrase = createAsyncThunk< { applicationState: APPLICATION_STATE; isCorrectPhrase: boolean }, string, @@ -961,6 +1011,30 @@ const authSlice = createSlice({ error: errorMessage, }; }); + builder.addCase(recoverAccountFromSecretKey.fulfilled, (state, action) => { + const { publicKey, allAccounts, hasPrivateKey } = action.payload || { + publicKey: "", + allAccounts: [], + hasPrivateKey: false, + }; + + return { + ...state, + error: "", + allAccounts, + hasPrivateKey, + applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + publicKey, + }; + }); + builder.addCase(recoverAccountFromSecretKey.rejected, (state, action) => { + const { errorMessage } = action.payload || { errorMessage: "" }; + + return { + ...state, + error: errorMessage, + }; + }); builder.addCase(confirmMnemonicPhrase.rejected, (state, action) => { const { applicationState, errorMessage } = action.payload || { errorMessage: "", @@ -1110,6 +1184,7 @@ const authSlice = createSlice({ importHardwareWallet, makeAccountActive, recoverAccount, + recoverAccountFromSecretKey, confirmPassword, migrateAccounts, ), diff --git a/extension/src/popup/helpers/__tests__/hasRecoveryPhrase.test.ts b/extension/src/popup/helpers/__tests__/hasRecoveryPhrase.test.ts new file mode 100644 index 0000000000..bafcb9c642 --- /dev/null +++ b/extension/src/popup/helpers/__tests__/hasRecoveryPhrase.test.ts @@ -0,0 +1,30 @@ +import { SecretSource, SecretSourceType } from "@shared/api/types"; +import { hasRecoveryPhrase } from "../hasRecoveryPhrase"; + +const phrase: SecretSource = { + id: "p", + type: SecretSourceType.PHRASE, + label: "Seed Phrase 1", + createdAt: 1, +}; +const secretKey: SecretSource = { + id: "k", + type: SecretSourceType.SECRET_KEY, + label: "Secret Key 1", + createdAt: 2, +}; + +describe("hasRecoveryPhrase", () => { + it("is true for an empty list", () => { + expect(hasRecoveryPhrase([])).toBe(true); + }); + it("is true with a phrase", () => { + expect(hasRecoveryPhrase([phrase])).toBe(true); + }); + it("is false with only a secret key", () => { + expect(hasRecoveryPhrase([secretKey])).toBe(false); + }); + it("is true with a secret key and a phrase", () => { + expect(hasRecoveryPhrase([secretKey, phrase])).toBe(true); + }); +}); diff --git a/extension/src/popup/helpers/__tests__/parseImportSecret.test.ts b/extension/src/popup/helpers/__tests__/parseImportSecret.test.ts new file mode 100644 index 0000000000..99c68582fd --- /dev/null +++ b/extension/src/popup/helpers/__tests__/parseImportSecret.test.ts @@ -0,0 +1,71 @@ +import { Keypair } from "stellar-sdk"; + +import { parseImportSecret } from "popup/helpers/parseImportSecret"; + +const PHRASE_12 = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const PHRASE_24 = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + +describe("parseImportSecret", () => { + it("classifies a 12-word phrase", () => { + expect(parseImportSecret(PHRASE_12)).toEqual({ + type: "phrase", + mnemonic: PHRASE_12, + }); + }); + + it("classifies a 24-word phrase", () => { + expect(parseImportSecret(PHRASE_24)).toEqual({ + type: "phrase", + mnemonic: PHRASE_24, + }); + }); + + it("normalizes case and whitespace in a phrase", () => { + const messy = ` ABANDON abandon\n${PHRASE_12.split(" ") + .slice(2) + .join(" ") + .toUpperCase()} `; + expect(parseImportSecret(messy)).toEqual({ + type: "phrase", + mnemonic: PHRASE_12, + }); + }); + + it("classifies a secret key with surrounding whitespace", () => { + const secret = Keypair.random().secret(); + expect(parseImportSecret(` ${secret}\n`)).toEqual({ + type: "secret_key", + secretKey: secret, + }); + }); + + it("rejects a public key", () => { + expect(parseImportSecret(Keypair.random().publicKey())).toEqual({ + type: "invalid", + }); + }); + + it("rejects a truncated secret key", () => { + const secret = Keypair.random().secret(); + expect(parseImportSecret(secret.slice(0, -1))).toEqual({ type: "invalid" }); + }); + + it("rejects a phrase with a bad checksum", () => { + const words = PHRASE_12.split(" "); + words[11] = "abandon"; + expect(parseImportSecret(words.join(" "))).toEqual({ type: "invalid" }); + }); + + it("rejects a 13-word phrase", () => { + expect(parseImportSecret(`${PHRASE_12} about`)).toEqual({ + type: "invalid", + }); + }); + + it("rejects empty and whitespace-only input", () => { + expect(parseImportSecret("")).toEqual({ type: "invalid" }); + expect(parseImportSecret(" ")).toEqual({ type: "invalid" }); + }); +}); diff --git a/extension/src/popup/helpers/hasRecoveryPhrase.ts b/extension/src/popup/helpers/hasRecoveryPhrase.ts new file mode 100644 index 0000000000..ad5b9a594e --- /dev/null +++ b/extension/src/popup/helpers/hasRecoveryPhrase.ts @@ -0,0 +1,8 @@ +import { SecretSource, SecretSourceType } from "@shared/api/types"; + +// A wallet onboarded from a secret key holds only SECRET_KEY sources and has +// no recovery phrase to show. An empty list means sources haven't loaded, so +// stay permissive. +export const hasRecoveryPhrase = (sources: SecretSource[]): boolean => + sources.length === 0 || + sources.some((s) => s.type === SecretSourceType.PHRASE); diff --git a/extension/src/popup/helpers/parseImportSecret.ts b/extension/src/popup/helpers/parseImportSecret.ts new file mode 100644 index 0000000000..1f30a1033d --- /dev/null +++ b/extension/src/popup/helpers/parseImportSecret.ts @@ -0,0 +1,26 @@ +import { StrKey } from "stellar-sdk"; + +import { + isValidImportMnemonic, + normalizeMnemonic, +} from "@shared/api/helpers/mnemonic"; + +export type ParsedImportSecret = + | { type: "phrase"; mnemonic: string } + | { type: "secret_key"; secretKey: string } + | { type: "invalid" }; + +/** + * Classify onboarding import input as a Stellar secret key, a BIP-39 + * recovery phrase, or neither. The secret key is checked first. + */ +export const parseImportSecret = (input: string): ParsedImportSecret => { + const trimmed = input.trim(); + if (StrKey.isValidEd25519SecretSeed(trimmed)) { + return { type: "secret_key", secretKey: trimmed }; + } + if (isValidImportMnemonic(input)) { + return { type: "phrase", mnemonic: normalizeMnemonic(input) }; + } + return { type: "invalid" }; +}; diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 983fba8694..eedbe52bd6 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -240,6 +240,7 @@ "Enabled": "Enabled", "Enabling these may impact the security of your wallets and result in loss of funds": "Enabling these may impact the security of your wallets and result in loss of funds.", "Enter a Stellar Asset List compatible URL": "Enter a Stellar Asset List compatible URL", + "Enter a valid recovery phrase or secret key": "Enter a valid recovery phrase or secret key", "Enter address": "Enter address", "Enter an amount": "Enter an amount", "ENTER BIP PATH": "ENTER BIP PATH", @@ -255,9 +256,11 @@ "Enter your account password to verify your account.": "Enter your account password to verify your account.", "Enter your mnemonic phrase to restore your wallet.": "Enter your mnemonic phrase to restore your wallet.", "Enter your password": "Enter your password", + "Enter your recovery phrase or secret key": "Enter your recovery phrase or secret key", "Error": "Error", "Error adding recovery phrase": "Error adding recovery phrase", "Error connecting. Please try again.": "Error connecting. Please try again.", + "Error importing account": "Error importing account", "Executable Owner": "Executable Owner", "Executable Tag": "Executable Tag", "Executable Type": "Executable Type", @@ -322,6 +325,7 @@ "Hidden collectibles": "Hidden collectibles", "Hidden Collectibles": "Hidden Collectibles", "Hidden tokens": "Hidden tokens", + "Hide": "Hide", "Hide {{code}}": "Hide {{code}}", "Hide collectible": "Hide collectible", "Hide payments smaller than 0.1 XLM": "Hide payments smaller than 0.1 XLM", @@ -358,11 +362,14 @@ "Import Secret Key": "Import Secret Key", "Import Stellar Secret Key": "Import Stellar Secret Key", "Import wallet": "Import wallet", + "Import Wallet": "Import Wallet", "Import wallet from recovery phrase": "Import wallet from recovery phrase", "Import wallets using a recovery phrase.": "Import wallets using a recovery phrase.", + "Import your wallet using a recovery phrase or secret key": "Import your wallet using a recovery phrase or secret key", "Important": "Important", "Important, Please Read": "Important, Please Read", "Imported": "Imported", + "Importing your wallet…": "Importing your wallet…", "In this process, Varial will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.": "In this process, Varial will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.", "Inclusion Fee": "Inclusion Fee", "Incorrect password": "Incorrect password", @@ -621,6 +628,7 @@ "Share feedback": "Share feedback", "Share feedback via form": "Share feedback via form", "Should be benign": "Should be benign", + "Show": "Show", "Show collectible": "Show collectible", "Show hidden": "Show hidden", "Show QR code": "Show QR code", @@ -700,6 +708,7 @@ "The destination account doesn't exist": "The destination account doesn't exist.", "The destination account doesn’t exist": "The destination account doesn’t exist", "The destination account must opt to accept this asset before receiving it.": "The destination account must opt to accept this asset before receiving it.", + "The phrase you entered is incorrect": "The phrase you entered is incorrect", "The requester expects you to sign this message on": "The requester expects you to sign this message on", "The secret phrase you entered is incorrect.": "The secret phrase you entered is incorrect.", "The signature returned by your Ledger could not be verified. Disconnect any other devices and try again. If this keeps happening, update the Stellar app on your Ledger.": "The signature returned by your Ledger could not be verified. Disconnect any other devices and try again. If this keeps happening, update the Stellar app on your Ledger.", @@ -941,5 +950,6 @@ "Your Stellar secret key": "Your Stellar secret key", "Your swap data could not be fetched at this time.": "Your swap data could not be fetched at this time.", "Your tokens": "Your tokens", + "Your wallet is ready!": "Your wallet is ready!", "Your wallets could not be fetched at this time.": "Your wallets could not be fetched at this time." } diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index cf48ac54b1..e27c46f14a 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -240,6 +240,7 @@ "Enabled": "Habilitado", "Enabling these may impact the security of your wallets and result in loss of funds": "Habilitar essas configurações pode impactar a segurança de suas carteiras e resultar em perda de fundos.", "Enter a Stellar Asset List compatible URL": "Inserir URL compatível com Stellar Asset List", + "Enter a valid recovery phrase or secret key": "Insira uma frase de recuperação ou chave secreta válida", "Enter address": "Inserir endereço", "Enter an amount": "Digite um valor", "ENTER BIP PATH": "INSERIR CAMINHO BIP", @@ -255,9 +256,11 @@ "Enter your account password to verify your account.": "Digite a senha da sua conta para verificar sua conta.", "Enter your mnemonic phrase to restore your wallet.": "Insira sua frase mnemônica para restaurar sua carteira.", "Enter your password": "Insira sua senha", + "Enter your recovery phrase or secret key": "Insira sua frase de recuperação ou chave secreta", "Error": "Erro", "Error adding recovery phrase": "Erro ao adicionar a frase de recuperação", "Error connecting. Please try again.": "Erro ao conectar. Por favor, tente novamente.", + "Error importing account": "Erro ao importar a conta", "Executable Owner": "Proprietário do Executável", "Executable Tag": "Tag do Executável", "Executable Type": "Tipo Executável", @@ -322,6 +325,7 @@ "Hidden collectibles": "Colecionáveis ocultos", "Hidden Collectibles": "Colecionáveis Ocultos", "Hidden tokens": "Tokens ocultos", + "Hide": "Ocultar", "Hide {{code}}": "Ocultar {{code}}", "Hide collectible": "Ocultar colecionável", "Hide payments smaller than 0.1 XLM": "Ocultar pagamentos menores que 0.1 XLM", @@ -358,11 +362,14 @@ "Import Secret Key": "Import Secret Key", "Import Stellar Secret Key": "Importar Stellar Secret Key", "Import wallet": "Importar carteira", + "Import Wallet": "Importar carteira", "Import wallet from recovery phrase": "Importar carteira da frase de recuperação", "Import wallets using a recovery phrase.": "Importe carteiras usando uma frase de recuperação.", + "Import your wallet using a recovery phrase or secret key": "Importe sua carteira usando uma frase de recuperação ou chave secreta", "Important": "Importante", "Important, Please Read": "Importante, Por Favor Leia", "Imported": "Importado", + "Importing your wallet…": "Importando sua carteira…", "In this process, Varial will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.": "Neste processo, o Varial criará uma nova frase de backup para você e migrará seus lumens, trustlines e ativos para a nova conta.", "Inclusion Fee": "Taxa de Inclusão", "Incorrect password": "Senha incorreta", @@ -621,6 +628,7 @@ "Share feedback": "Compartilhar feedback", "Share feedback via form": "Compartilhe seu feedback pelo formulário", "Should be benign": "Deve ser benigno", + "Show": "Mostrar", "Show collectible": "Show collectible", "Show hidden": "Mostrar ocultos", "Show QR code": "Mostrar código QR", @@ -700,6 +708,7 @@ "The destination account doesn't exist": "A conta de destino não existe", "The destination account doesn’t exist": "A conta de destino não existe", "The destination account must opt to accept this asset before receiving it.": "A conta de destino deve optar por aceitar este ativo antes de recebê-lo.", + "The phrase you entered is incorrect": "A frase que você inseriu está incorreta", "The requester expects you to sign this message on": "O solicitante espera que você assine esta mensagem em", "The secret phrase you entered is incorrect.": "A frase secreta que você inseriu está incorreta.", "The signature returned by your Ledger could not be verified. Disconnect any other devices and try again. If this keeps happening, update the Stellar app on your Ledger.": "Não foi possível verificar a assinatura retornada pelo seu Ledger. Desconecte outros dispositivos e tente novamente. Se o problema persistir, atualize o app Stellar no seu Ledger.", @@ -941,5 +950,6 @@ "Your Stellar secret key": "Sua Stellar secret key", "Your swap data could not be fetched at this time.": "Seus dados de troca não puderam ser buscados neste momento.", "Your tokens": "Seus tokens", + "Your wallet is ready!": "Sua carteira está pronta!", "Your wallets could not be fetched at this time.": "Suas carteiras não puderam ser buscadas neste momento." } diff --git a/extension/src/popup/metrics/__tests__/authServices.test.ts b/extension/src/popup/metrics/__tests__/authServices.test.ts new file mode 100644 index 0000000000..a463ae88e4 --- /dev/null +++ b/extension/src/popup/metrics/__tests__/authServices.test.ts @@ -0,0 +1,88 @@ +jest.mock("helpers/metrics", () => ({ + registerHandler: jest.fn(), + emitMetric: jest.fn(), +})); +jest.mock("popup/App", () => ({})); +jest.mock("popup/ducks/accountServices", () => { + const thunk = (name: string) => ({ + fulfilled: { type: `${name}/fulfilled` }, + rejected: { type: `${name}/rejected` }, + }); + return { + createAccount: thunk("createAccount"), + confirmPassword: thunk("confirmPassword"), + confirmMnemonicPhrase: thunk("confirmMnemonicPhrase"), + recoverAccount: thunk("recoverAccount"), + recoverAccountFromSecretKey: thunk("recoverAccountFromSecretKey"), + }; +}); + +import { registerHandler, emitMetric } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { + recoverAccount, + recoverAccountFromSecretKey, +} from "popup/ducks/accountServices"; + +import "popup/metrics/authServices"; + +type Handler = (state: unknown, action: unknown) => void; + +const handlerFor = (actionCreator: unknown): Handler => { + const call = (registerHandler as jest.Mock).mock.calls.find( + ([creator]) => creator === actionCreator, + ); + if (!call) { + throw new Error("no handler registered"); + } + return call[1] as Handler; +}; + +describe("account recovery metrics", () => { + beforeEach(() => { + (emitMetric as jest.Mock).mockClear(); + }); + + it("emits completed with recovery_method secret_key", () => { + handlerFor(recoverAccountFromSecretKey.fulfilled)({}, {}); + expect(emitMetric).toHaveBeenCalledWith( + METRIC_NAMES.accountRecoveryCompleted, + { recovery_method: "secret_key" }, + ); + }); + + it("emits failed with recovery_method and reason_code for a secret key", () => { + handlerFor(recoverAccountFromSecretKey.rejected)( + {}, + { payload: { errorMessage: "Invalid secret key" } }, + ); + expect(emitMetric).toHaveBeenCalledWith( + METRIC_NAMES.accountRecoveryFailed, + { + recovery_method: "secret_key", + reason_code: "Invalid secret key", + }, + ); + }); + + it("keeps recovery_phrase on the phrase completed event", () => { + handlerFor(recoverAccount.fulfilled)({}, {}); + expect(emitMetric).toHaveBeenCalledWith( + METRIC_NAMES.accountRecoveryCompleted, + { recovery_method: "recovery_phrase" }, + ); + }); + + it("leaves the phrase failed payload as reason_code only", () => { + handlerFor(recoverAccount.rejected)( + {}, + { payload: { errorMessage: "The phrase you entered is incorrect" } }, + ); + expect(emitMetric).toHaveBeenCalledWith( + METRIC_NAMES.accountRecoveryFailed, + { + reason_code: "The phrase you entered is incorrect", + }, + ); + }); +}); diff --git a/extension/src/popup/metrics/authServices.ts b/extension/src/popup/metrics/authServices.ts index e5dd02ad7f..b8e29a9828 100644 --- a/extension/src/popup/metrics/authServices.ts +++ b/extension/src/popup/metrics/authServices.ts @@ -8,6 +8,7 @@ import { confirmPassword, confirmMnemonicPhrase, recoverAccount, + recoverAccountFromSecretKey, } from "popup/ducks/accountServices"; import { AppState } from "popup/App"; @@ -60,3 +61,20 @@ registerHandler(recoverAccount.rejected, (_state, action) => { reason_code: scrubStrKeys(errorMessage) ?? errorMessage, }); }); + +registerHandler(recoverAccountFromSecretKey.fulfilled, () => { + emitMetric(METRIC_NAMES.accountRecoveryCompleted, { + recovery_method: "secret_key", + }); +}); +registerHandler( + recoverAccountFromSecretKey.rejected, + (_state, action) => { + const { errorMessage } = action.payload; + + emitMetric(METRIC_NAMES.accountRecoveryFailed, { + recovery_method: "secret_key", + reason_code: scrubStrKeys(errorMessage) ?? errorMessage, + }); + }, +); diff --git a/extension/src/popup/views/DisplayBackupPhrase/index.tsx b/extension/src/popup/views/DisplayBackupPhrase/index.tsx index e402a387ac..f72c265ba8 100644 --- a/extension/src/popup/views/DisplayBackupPhrase/index.tsx +++ b/extension/src/popup/views/DisplayBackupPhrase/index.tsx @@ -2,11 +2,14 @@ import React, { useEffect, useState } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { Button, Input, Notification } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; import { Field, Form, Formik } from "formik"; import { showBackupPhrase } from "@shared/api/internal"; import { ROUTES } from "popup/constants/routes"; +import { secretSourcesSelector } from "popup/ducks/accountServices"; +import { hasRecoveryPhrase } from "popup/helpers/hasRecoveryPhrase"; import { closeWindow, navigateTo, openTab } from "popup/helpers/navigate"; import { emitScreenViewed } from "helpers/metrics"; @@ -31,6 +34,7 @@ export const DisplayBackupPhrase = () => { const [isPhraseUnlocked, setIsPhraseUnlocked] = useState(false); const [mnemonicPhrase, setMnemonicPhrase] = useState(""); const { state, fetchData } = useGetAppData(); + const secretSources = useSelector(secretSourcesSelector); useEffect(() => { // Canonical cross-platform names (RFC #2883): mobile uses @@ -50,6 +54,10 @@ export const DisplayBackupPhrase = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + if (!hasRecoveryPhrase(secretSources)) { + return ; + } + if ( state.state === RequestState.IDLE || state.state === RequestState.LOADING diff --git a/extension/src/popup/views/RecoverAccount/ImportStatus.tsx b/extension/src/popup/views/RecoverAccount/ImportStatus.tsx new file mode 100644 index 0000000000..825447988e --- /dev/null +++ b/extension/src/popup/views/RecoverAccount/ImportStatus.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { Text } from "@stellar/design-system"; + +import { View } from "popup/basics/layout/View"; +import LogoVarial from "popup/assets/logo-varial.svg?react"; + +// Full-screen brand mark with one line of text: the "importing" and +// "ready" states of the onboarding import. +export const ImportStatus = ({ text }: { text: string }) => ( + +
+
+
+); diff --git a/extension/src/popup/views/RecoverAccount/index.tsx b/extension/src/popup/views/RecoverAccount/index.tsx index fa727b26b0..03fa0b30f5 100644 --- a/extension/src/popup/views/RecoverAccount/index.tsx +++ b/extension/src/popup/views/RecoverAccount/index.tsx @@ -1,16 +1,16 @@ import React, { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useDispatch, useSelector } from "react-redux"; -import { Formik, Form } from "formik"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { Formik } from "formik"; import { object as YupObject } from "yup"; import { + Button, Card, - Input, + Heading, Icon, - Button, - Text, - Toggle, Notification, + Text, + Textarea, } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; @@ -21,149 +21,117 @@ import { initialValues, FormValues, } from "popup/components/accountCreator/PasswordForm"; -import { - Onboarding, - OnboardingError, - OnboardingModal, -} from "popup/components/Onboarding"; +import { Onboarding, OnboardingError } from "popup/components/Onboarding"; +import { BackButton } from "popup/basics/buttons/BackButton"; import { View } from "popup/basics/layout/View"; import { ROUTES } from "popup/constants/routes"; import { navigateTo } from "popup/helpers/navigate"; +import { parseImportSecret } from "popup/helpers/parseImportSecret"; import { password as passwordValidator, confirmPassword as confirmPasswordValidator, termsOfUse as termsofUseValidator, } from "popup/helpers/validators"; -import { authErrorSelector, recoverAccount } from "popup/ducks/accountServices"; +import { + clearApiError, + recoverAccount, + recoverAccountFromSecretKey, +} from "popup/ducks/accountServices"; import "./styles.scss"; import { RequestState } from "constants/request"; import { Loading } from "popup/components/Loading"; import { useRecoverAccountData } from "./hooks/useGetRecoverAccountData"; +import { ImportStatus } from "./ImportStatus"; -interface PhraseInputProps { - phraseInput: string; - index: number; - handleMnemonicInputChange: (value: string, index: number) => void; - isTextShowing: boolean; - isLongPhrase: boolean; - handlePaste: (e: React.ClipboardEvent) => void; - pastedValue?: string; -} +// How long "Your wallet is ready!" stays up before the success route. +export const READY_SCREEN_MS = 1500; -const PhraseInput = ({ - phraseInput, - index, - handleMnemonicInputChange, - isTextShowing, - isLongPhrase, - handlePaste, - pastedValue = "", -}: PhraseInputProps) => { - const [inputValue, setInputValue] = useState(pastedValue); +// Background errors that mean the value itself was rejected. +const INVALID_INPUT_ERRORS = ["Invalid mnemonic phrase", "Invalid secret key"]; - useEffect(() => { - setInputValue(""); - }, [isLongPhrase]); +const RecoverAccountSchema = YupObject().shape({ + password: passwordValidator, + confirmPassword: confirmPasswordValidator, + termsOfUse: termsofUseValidator, +}); - useEffect(() => { - setInputValue(pastedValue); - }, [pastedValue]); +/* + Onboarding import: one text box that takes a recovery phrase or a secret + key, then a password, then "importing" and "ready" before the success route. - return ( -
-
- {(index + 1).toString().padStart(2, "0")} -
- { - handleMnemonicInputChange(e.target.value, index); - setInputValue(e.target.value); - }} - onPaste={handlePaste} - type={isTextShowing ? "text" : "password"} - value={inputValue} - /> -
- ); -}; + input / password: the password step is its own history entry, so the + password form's Back button (navigate(-1)) returns to the text box with the + value still in component state. Only Next opens that step; the history + state alone does not. + importing / ready: full-screen status while the background stores the + account. -const SHORT_PHRASE = 12; -const LONG_PHRASE = 24; - -const buildMnemonicPhrase = (mnemonicPhraseArr: string[]) => - mnemonicPhraseArr.join(" ").trim(); + The value is dropped from state once the import succeeds. +*/ +type Step = "input" | "password" | "importing" | "ready"; +type ImportPhase = "form" | "importing" | "ready"; export const RecoverAccount = () => { const { t } = useTranslation(); - - const authError = useSelector(authErrorSelector); const navigate = useNavigate(); - const RecoverAccountSchema = YupObject().shape({ - password: passwordValidator, - confirmPassword: confirmPasswordValidator, - termsOfUse: termsofUseValidator, - }); - + const location = useLocation(); const dispatch: AppDispatch = useDispatch(); - const [isLongPhrase, setIsLongPhrase] = useState(false); - const [isTextShowing, setIsTextShowing] = useState(false); - const [phraseInputs, setPhraseInputs] = useState([] as string[]); - const [mnemonicPhraseArr, setMnemonicPhraseArr] = useState([] as string[]); - const [password, setPassword] = useState(""); - const [pastedValues, setPastedValues] = useState([]); - const [passwordFormValues, setPasswordFormValues] = - useState(initialValues); - const { state, fetchData } = useRecoverAccountData(); - - const handleConfirm = (values: FormValues) => { - setPassword(values.password); - setPasswordFormValues(values); - }; - const publicKeyRef = useRef( - state.state === RequestState.SUCCESS && state.data.account.publicKey, + const [secretInput, setSecretInput] = useState(""); + const [inputError, setInputError] = useState(""); + const [isInputVisible, setIsInputVisible] = useState(false); + const [phase, setPhase] = useState("form"); + // Enter in the password form, or a second click before it unmounts, + // would otherwise start a second import. + const isImporting = useRef(false); + const readyTimer = useRef | undefined>( + undefined, ); + const { state, fetchData } = useRecoverAccountData(); - useEffect(() => { - if ( - state.state === RequestState.SUCCESS && - state.data.account.publicKey && - state.data.account.publicKey !== publicKeyRef.current - ) { - navigateTo(ROUTES.recoverAccountSuccess, navigate); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [navigate, publicKeyRef, state.state]); - - useEffect(() => { - const phraseInputsArr: string[] = []; - const PHRASE_LENGTH = isLongPhrase ? LONG_PHRASE : SHORT_PHRASE; - - for (let i = 1; i <= PHRASE_LENGTH; i++) { - phraseInputsArr.push(`MnemonicPhrase-${i}`); - } - setPhraseInputs(phraseInputsArr); + // Set by Next once the value parses; reset whenever it is edited. History + // state alone (it survives a reload under HashRouter) never opens the + // password step. + const [hasConfirmedInput, setHasConfirmedInput] = useState(false); + const isPasswordEntry = + (location.state as { step?: string } | null)?.step === "password"; + // Read by the failure path, which can run after browser Back has already + // left the password entry. + const isPasswordEntryRef = useRef(isPasswordEntry); + isPasswordEntryRef.current = isPasswordEntry; - if (PHRASE_LENGTH === SHORT_PHRASE) { - // when going back to 12 words, clear all the fields - setMnemonicPhraseArr([]); - } - }, [isLongPhrase]); + let step: Step = + isPasswordEntry && hasConfirmedInput && secretInput ? "password" : "input"; + if (phase !== "form") { + step = phase; + } useEffect(() => { - const getData = async () => { - await fetchData(); + // A reload keeps { step: "password" } on the entry; drop it so the + // input step owns this entry again. + if (isPasswordEntry) { + navigate(location.pathname, { replace: true, state: null }); + } + fetchData().catch(() => { + // The hook keeps the error state; it's rendered below. + }); + return () => { + clearTimeout(readyTimer.current); + dispatch(clearApiError()); }; - getData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + if (step === "importing") { + return ; + } + + if (step === "ready") { + return ; + } + if ( state.state === RequestState.IDLE || state.state === RequestState.LOADING @@ -193,155 +161,202 @@ export const RecoverAccount = () => { const isOverwritingAccount = applicationState === APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED; - const handleMnemonicInputChange = (value: string, i: number) => { - const arr = [...mnemonicPhraseArr]; - arr[i] = value; + // Background and thunk failures arrive as English strings; anything + // unrecognized (or empty) falls back to the generic import error. + const formatImportError = (error: string) => { + if (INVALID_INPUT_ERRORS.includes(error)) { + return t("Enter a valid recovery phrase or secret key"); + } + if (error === "The phrase you entered is incorrect") { + return t("The phrase you entered is incorrect"); + } + return t("Error importing account"); + }; - setMnemonicPhraseArr(arr); + // There's no Paste button: extension pages can't read the clipboard + // without the clipboardRead permission. A keyboard paste lands in the + // textarea natively; afterwards the clipboard is cleared. + const handleKeyboardPaste = async () => { + try { + await navigator.clipboard.writeText(""); + } catch (e) { + // Nothing to do if the clipboard can't be written. + } }; - const handleSubmit = async () => { - await dispatch( - recoverAccount({ - password, - mnemonicPhrase: buildMnemonicPhrase(mnemonicPhraseArr), - isOverwritingAccount, - }), - ); - await fetchData(); + const handleNext = () => { + if (parseImportSecret(secretInput).type === "invalid") { + setInputError(t("Enter a valid recovery phrase or secret key")); + return; + } + setInputError(""); + setHasConfirmedInput(true); + // PasswordForm shows the stored auth error on its fields. + dispatch(clearApiError()); + navigate(location.pathname, { state: { step: "password" } }); }; - const handlePaste = async (e: React.ClipboardEvent) => { - const pastedData = e.clipboardData.getData("text/plain"); - const pastedWords = pastedData.split(" "); + // Back to the text box with an error. Only pop history when the current + // entry is the password one this view pushed; otherwise popping would + // leave the import route. + const returnToInput = (error: string) => { + setInputError(error); + setHasConfirmedInput(false); + setPhase("form"); + if (isPasswordEntryRef.current) { + navigate(-1); + } + }; - if (pastedWords.length) { - e.preventDefault(); - setPastedValues(pastedWords); - setMnemonicPhraseArr(pastedWords); - await navigator.clipboard.writeText(""); + const handleImport = async (values: FormValues) => { + if (isImporting.current) { + return; + } + const parsed = parseImportSecret(secretInput); + if (parsed.type === "invalid") { + returnToInput(t("Enter a valid recovery phrase or secret key")); + return; + } + isImporting.current = true; + setPhase("importing"); + + try { + const res = + parsed.type === "phrase" + ? await dispatch( + recoverAccount({ + password: values.password, + mnemonicPhrase: parsed.mnemonic, + isOverwritingAccount, + }), + ) + : await dispatch( + recoverAccountFromSecretKey({ + password: values.password, + secretKey: parsed.secretKey, + isOverwritingAccount, + }), + ); + + if ( + recoverAccount.fulfilled.match(res) || + recoverAccountFromSecretKey.fulfilled.match(res) + ) { + setSecretInput(""); + try { + await fetchData(); + } catch (e) { + // The account is stored; the next screen loads its own data. + } + setPhase("ready"); + readyTimer.current = setTimeout(() => { + navigateTo(ROUTES.recoverAccountSuccess, navigate); + }, READY_SCREEN_MS); + return; + } + + returnToInput(formatImportError(res.payload?.errorMessage || "")); + } finally { + isImporting.current = false; } }; - return ( - + if (step === "password") { + return ( {({ dirty, touched, isSubmitting, isValid, errors, values }) => ( - <> - {password ? ( -
-
- - - - {t( - "Enter your mnemonic phrase to restore your wallet.", - )} -
- {t( - "You can also paste the phrase in the first box.", - )} -
- - } - > - -
-
- {phraseInputs.map((phraseInput, i) => ( - - ))} -
-
-
-
-
-
-
{SHORT_PHRASE} word
- setIsLongPhrase(!isLongPhrase)} - /> -
{LONG_PHRASE} word
-
-
- -
-
- -
- -
-
-
- -
-
-
- ) : ( - - )} - + )}
-
+ ); + } + + return ( + +
+ + +
+ { + setSecretInput(""); + navigateTo(ROUTES.welcome, navigate); + }} + /> +
+ + {t("Import Wallet")} + + + {t( + "Import your wallet using a recovery phrase or secret key", + )} + +
+
+