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..702a794062 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, + waitForOnboardingReady, +} 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 waitForOnboardingReady({ page }); + 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..e512f18319 100644 --- a/extension/e2e-tests/helpers/onboarding.ts +++ b/extension/e2e-tests/helpers/onboarding.ts @@ -1,74 +1,93 @@ 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 "Your wallet is ready!", the last screen of both onboarding + * flows (create and import). The status attribute is checked instead of the copy so translated + * runs work too. */ -export const fillMnemonicInputs = async ({ +export const waitForOnboardingReady = async ({ page, - words, + timeout = DEFAULT_IMPORT_TIMEOUT_MS, }: { page: Page; - words: string[]; + timeout?: number; }) => { - 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.locator('[data-testid="onboarding-status"][data-status="ready"]'), + ).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 ready screen. */ -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 waitForOnboardingReady({ page, timeout }); }; diff --git a/extension/e2e-tests/onboarding.test.ts b/extension/e2e-tests/onboarding.test.ts index 7c509313b3..3d23e8374c 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, + waitForOnboardingReady, } from "./helpers/onboarding"; +import { stubAllExternalApis } from "./helpers/stubs"; import type { Page } from "@playwright/test"; import { goToSettings } from "./helpers/network"; @@ -89,46 +91,75 @@ test("Create new wallet", async ({ page }) => { await page.getByLabel(words[i]).check({ force: true }); } await page.getByTestId("display-mnemonic-phrase-confirm-btn").click(); - await expect(page.getByText("You’re all set!")).toBeVisible(); + await waitForOnboardingReady({ page }); await expectPageToHaveScreenshot({ page, screenshot: "wallet-create-complete-page.png", }); }); -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 waitForOnboardingReady({ 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 waitForOnboardingReady({ 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 waitForOnboardingReady({ 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 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(() => - 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 waitForOnboardingReady({ 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 waitForOnboardingReady({ 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"); @@ -483,7 +450,7 @@ test("Reset wallet and create a new wallet", async ({ await welcomePage.getByText("Create new wallet").click(); await fillNewPassword(welcomePage); await welcomePage.getByText("Do this later").click(); - await expect(welcomePage.getByText("You’re all set!")).toBeVisible(); + await waitForOnboardingReady({ page: welcomePage }); await expectOnlyNewWalletAccount(welcomePage, extensionId); await unlockOriginalPage(page); @@ -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 waitForOnboardingReady({ page: welcomePage }); await expectOnlyNewWalletAccount(welcomePage, extensionId); await unlockOriginalPage(page); @@ -589,7 +547,7 @@ test("Overwrites account when user abandons mnemonic phrase confirmation", async await page.getByTestId("display-mnemonic-phrase-next-btn").click(); await page.getByTestId("display-mnemonic-phrase-skip-btn").click(); - await expect(page.getByText("You’re all set!")).toBeVisible(); + await waitForOnboardingReady({ page }); await page.goto(`chrome-extension://${extensionId}/index.html#/`); await expect(page.getByTestId("account-view")).toBeVisible({ @@ -660,7 +618,7 @@ test("Overwrites account when user abandons after password creation", async ({ ); await page.getByTestId("display-mnemonic-phrase-next-btn").click(); await page.getByTestId("display-mnemonic-phrase-skip-btn").click(); - await expect(page.getByText("You’re all set!")).toBeVisible(); + await waitForOnboardingReady({ page }); await page.goto(`chrome-extension://${extensionId}/index.html#/`); await expect(page.getByTestId("account-view")).toBeVisible({ @@ -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 }); - - const wrongWords = Array(TEST_MNEMONIC_12_WORDS.length).fill("wrong"); - await fillMnemonicInputs({ page, words: wrongWords }); + await openImportWallet({ page }); - 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 waitForOnboardingReady({ 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 waitForOnboardingReady({ 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 waitForOnboardingReady({ 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 b4681d911e..a0f6ac0eb7 100644 Binary files a/extension/e2e-tests/onboarding.test.ts-snapshots/wallet-import-12-word-phrase-page-chromium-darwin.png and b/extension/e2e-tests/onboarding.test.ts-snapshots/wallet-import-12-word-phrase-page-chromium-darwin.png differ 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 27bf673e7a..a0f6ac0eb7 100644 Binary files a/extension/e2e-tests/onboarding.test.ts-snapshots/wallet-import-24-word-phrase-page-chromium-darwin.png and b/extension/e2e-tests/onboarding.test.ts-snapshots/wallet-import-24-word-phrase-page-chromium-darwin.png differ 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__/recoverAccount.test.ts b/extension/src/background/messageListener/__tests__/recoverAccount.test.ts index eebc3097e2..4377b769d4 100644 --- a/extension/src/background/messageListener/__tests__/recoverAccount.test.ts +++ b/extension/src/background/messageListener/__tests__/recoverAccount.test.ts @@ -12,6 +12,8 @@ import { import { AUTH_KEYPAIR_VECTORS } from "@shared/api/helpers/authKeypairVectors"; import { SecretSourceType } from "@shared/api/types"; import { getMnemonics } from "background/helpers/session"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import { APPLICATION_ID } from "constants/localStorageTypes"; import { getPinnedSourceId, getSecretSources, @@ -78,6 +80,25 @@ describe("Recover account message listener", () => { jest.restoreAllMocks(); }); + it.each([ + APPLICATION_STATE.PASSWORD_CREATED, + APPLICATION_STATE.MNEMONIC_PHRASE_FAILED, + ])( + "marks the wallet onboarded over an abandoned create (%s)", + async (state) => { + await mockDataStorage.setItem(APPLICATION_ID, state); + + const res = (await recover()) as { applicationState: string }; + + expect(res.applicationState).toBe( + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + expect(await mockDataStorage.getItem(APPLICATION_ID)).toBe( + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + }, + ); + // Regression: the preload loop used to store derivation indices 1..n with // `imported: true`, so recovering a phrase with several funded accounts // tagged every account but the first as "Imported" in the wallets list. 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..8f1ca504b4 --- /dev/null +++ b/extension/src/background/messageListener/__tests__/recoverAccountFromSecretKey.test.ts @@ -0,0 +1,310 @@ +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 { setAccountProvenance } from "background/helpers/account-provenance"; +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(), +})); + +jest.mock("background/helpers/account-provenance", () => ({ + ...jest.requireActual("background/helpers/account-provenance"), + setAccountProvenance: 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 storedKeystoreKeys = async () => + Object.keys(await mockStorageApi.get(null)).filter((k) => + k.startsWith("stellarkeys:"), + ); + +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); + const actualProvenance = jest.requireActual( + "background/helpers/account-provenance", + ); + (setAccountProvenance as jest.Mock).mockImplementation( + actualProvenance.setAccountProvenance, + ); + }); + + 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.each([ + APPLICATION_STATE.PASSWORD_CREATED, + APPLICATION_STATE.MNEMONIC_PHRASE_FAILED, + ])( + "marks the wallet onboarded over an abandoned create (%s)", + async (state) => { + await mockDataStorage.setItem(APPLICATION_ID, state); + + const res = await recoverFromSecretKey(SECRET); + + expect(res.error).toBe(""); + expect(res.applicationState).toBe( + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + 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("removes the stored keystore when a write after storeKey fails", async () => { + (setAccountProvenance as jest.Mock).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 storedKeystoreKeys()).toEqual([]); + expect(await getSecretSources({ localStore: mockDataStorage })).toEqual({}); + expect(await mockDataStorage.getItem(APPLICATION_ID)).toBeFalsy(); + expect(startSession).not.toHaveBeenCalled(); + }); + + it("removes unlisted keystores left by an earlier attempt", async () => { + await mockDataStorage.setItem("stellarkeys:orphan", { id: "orphan" }); + + const res = await recoverFromSecretKey(SECRET); + + expect(res.error).toBe(""); + const keyIdList = (await mockDataStorage.getItem(KEY_ID_LIST)) as string[]; + expect(await storedKeystoreKeys()).toEqual( + keyIdList.map((id) => `stellarkeys:${id}`), + ); + }); + + 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..d10d772fe6 --- /dev/null +++ b/extension/src/background/messageListener/helpers/recovery-lifecycle.ts @@ -0,0 +1,143 @@ +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), and the secret-key + handler calls it again to roll back a failed store. +*/ +export const clearWalletForRecovery = async ({ + localStore, + sessionStore, +}: { + localStore: DataStorageAccess; + sessionStore: Store; +}) => { + // resets accounts list + sessionStore.dispatch(reset()); + + // Every keystore goes, listed or not. A store that failed after `storeKey` + // but before the list write leaves a keystore only this sweep can find. + const keystoreKeys = (await localStore.getAllKeys()).filter((key) => + key.startsWith("stellarkeys:"), + ); + await localStore.remove(keystoreKeys); + + const keyIdList = await getKeyIdList({ localStore }); + + if (keyIdList.length) { + /* Clear any existing account data while maintaining app settings */ + 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: marks it onboarded, 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; +}) => { + // Always onboarded: a recovery replaces any create flow left half done, + // whose PASSWORD_CREATED or MNEMONIC_PHRASE_FAILED would send the user + // back to wallet creation. + await localStore.setItem( + APPLICATION_ID, + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + + 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/Router.tsx b/extension/src/popup/Router.tsx index ea4692e62e..f5146c4593 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -36,7 +36,7 @@ import { PluginWallet } from "popup/views/AddAccount/connect/PluginWallet"; import { DeviceConnect } from "popup/views/AddAccount/connect/DeviceConnect"; import { GrantAccess } from "popup/views/GrantAccess"; import { MnemonicPhrase } from "popup/views/MnemonicPhrase"; -import { FullscreenSuccessMessage } from "popup/views/FullscreenSuccessMessage"; +import { OnboardingStatus } from "popup/components/OnboardingStatus"; import { RecoverAccount } from "popup/views/RecoverAccount"; import { AddToken } from "popup/views/AddToken"; import { SignTransaction } from "popup/views/SignTransaction"; @@ -288,7 +288,7 @@ export const Router = () => ( > } + element={} > ( }> } + element={} > { + const { t } = useTranslation(); + const text = + status === "ready" + ? t("Your wallet is ready!") + : t("Importing your wallet…"); + const [shownText, setShownText] = useState(text); + const isLeaving = shownText !== text; + + useEffect(() => { + if (!isLeaving) { + return undefined; + } + const timer = setTimeout(() => setShownText(text), STATUS_EXIT_MS); + return () => clearTimeout(timer); + }, [isLeaving, text]); + + return ( + +
+
+
+ ); +}; diff --git a/extension/src/popup/components/OnboardingStatus/styles.scss b/extension/src/popup/components/OnboardingStatus/styles.scss new file mode 100644 index 0000000000..81a57172d4 --- /dev/null +++ b/extension/src/popup/components/OnboardingStatus/styles.scss @@ -0,0 +1,51 @@ +@use "../../styles/utils.scss" as *; + +.OnboardingStatus { + align-items: center; + color: var(--sds-clr-gray-12); + display: flex; + flex-direction: column; + gap: #{pxToRem(16px)}; + height: 100%; + justify-content: center; + text-align: center; + + &__logo { + height: #{pxToRem(48px)}; + width: #{pxToRem(48px)}; + } + + // Copy swap between statuses: a short fade out, then the new line settles + // in. The exit length is STATUS_EXIT_MS in index.tsx. + &__text { + animation: onboarding-status-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both; + + &--leaving { + animation: onboarding-status-out 150ms cubic-bezier(0.23, 1, 0.32, 1) + forwards; + } + + @media (prefers-reduced-motion: reduce) { + &, + &--leaving { + animation: none; + } + } + } +} + +@keyframes onboarding-status-in { + from { + filter: blur(2px); + opacity: 0; + transform: translateY(4px); + } +} + +@keyframes onboarding-status-out { + to { + filter: blur(2px); + opacity: 0; + transform: translateY(-4px); + } +} 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..dff809be28 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", @@ -512,7 +519,6 @@ "Password": "Password", "Path": "Path", "Payment token logo": "Payment token logo", - "Pin Varial to the toolbar": "Pin Varial to the toolbar", "Please check if the network information is correct and try again.": "Please check if the network information is correct and try again.", "Please check the asset code or address.": "Please check the asset code or address.", "Please check the new rate and try again.": "Please check the new rate and try again.", @@ -621,6 +627,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 +707,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.", @@ -918,7 +926,6 @@ "You will permanently lose access to the account currently stored in Varial.": "You will permanently lose access to the account currently stored in Varial.", "You will permanently lose access to the account you started to create in Varial.": "You will permanently lose access to the account you started to create in Varial.", "You’ll still be able to import your current backup phrase into Varial and control current accounts as long as they were not merged into the new accounts.": "You’ll still be able to import your current backup phrase into Varial and control current accounts as long as they were not merged into the new accounts.", - "You’re all set!": "You’re all set!", "You’re good to go!": "You’re good to go!", "Your account balance is not sufficient for this transaction.": "Your account balance is not sufficient for this transaction.", "Your account balances could not be fetched at this time.": "Your account balances could not be fetched at this time.", @@ -941,5 +948,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..2dafd8b9c5 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", @@ -512,7 +519,6 @@ "Password": "Senha", "Path": "Caminho", "Payment token logo": "Logotipo do token de pagamento", - "Pin Varial to the toolbar": "Fixar Varial na toolbar", "Please check if the network information is correct and try again.": "Por favor, verifique se as informações da rede estão corretas e tente novamente.", "Please check the asset code or address.": "Por favor, verifique o código do ativo ou endereço.", "Please check the new rate and try again.": "Por favor, verifique a nova taxa e tente novamente.", @@ -621,6 +627,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 +707,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.", @@ -918,7 +926,6 @@ "You will permanently lose access to the account currently stored in Varial.": "Você perderá permanentemente o acesso à conta atualmente armazenada no Varial.", "You will permanently lose access to the account you started to create in Varial.": "Você perderá permanentemente o acesso à conta que começou a criar no Varial.", "You’ll still be able to import your current backup phrase into Varial and control current accounts as long as they were not merged into the new accounts.": "Você ainda poderá importar sua frase de backup atual para o Varial e controlar contas atuais, desde que não tenham sido mescladas nas novas contas.", - "You’re all set!": "Tudo pronto!", "You’re good to go!": "Você está pronto para começar!", "Your account balance is not sufficient for this transaction.": "O saldo da sua conta não é suficiente para esta transação.", "Your account balances could not be fetched at this time.": "Os saldos da sua conta não puderam ser buscados neste momento.", @@ -941,5 +948,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..75aadea900 --- /dev/null +++ b/extension/src/popup/metrics/__tests__/authServices.test.ts @@ -0,0 +1,120 @@ +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", + }, + ); + }); + + // `key_S…` has no word boundary before the key, so scrubStrKeys would + // let it through. + it("sends a fixed code for any other secret-key failure, never the error text", () => { + const secretKey = + "SBPQUZ6G4FZNWFHKUWC5BEYWF6R52E3SEP7R3GWYSM2XTKGF5LNTWW4R"; + handlerFor(recoverAccountFromSecretKey.rejected)( + {}, + { payload: { errorMessage: `could not store key_${secretKey}` } }, + ); + expect(emitMetric).toHaveBeenCalledWith( + METRIC_NAMES.accountRecoveryFailed, + { + recovery_method: "secret_key", + reason_code: "store_failed", + }, + ); + expect(JSON.stringify((emitMetric as jest.Mock).mock.calls)).not.toContain( + secretKey, + ); + }); + + it("sends store_failed when the rejection has no payload", () => { + handlerFor(recoverAccountFromSecretKey.rejected)({}, {}); + expect(emitMetric).toHaveBeenCalledWith( + METRIC_NAMES.accountRecoveryFailed, + { + recovery_method: "secret_key", + reason_code: "store_failed", + }, + ); + }); + + 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..f3aa0c1ee4 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,26 @@ 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?.errorMessage; + + // A fixed code, never the error text: a raw secret key is in hand on + // this path, and scrubStrKeys misses a key glued to `_` or a letter + // (e.g. `key_S…`). + emitMetric(METRIC_NAMES.accountRecoveryFailed, { + recovery_method: "secret_key", + reason_code: + errorMessage === "Invalid secret key" + ? "invalid_secret_key" + : "store_failed", + }); + }, +); 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/FullscreenSuccessMessage/index.tsx b/extension/src/popup/views/FullscreenSuccessMessage/index.tsx deleted file mode 100644 index aefe3b95b4..0000000000 --- a/extension/src/popup/views/FullscreenSuccessMessage/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Text, Icon } from "@stellar/design-system"; - -import { Onboarding, OnboardingModal } from "popup/components/Onboarding"; -import { View } from "popup/basics/layout/View"; - -import LogoWelcome from "popup/assets/logo-varial.svg?react"; - -import "./styles.scss"; - -export const FullscreenSuccessMessage = () => { - const { t } = useTranslation(); - - return ( - <> -
-
- {t("Pin Varial to the toolbar")} -
-
-
-
- -
-
-
-
- -
-
- {t("Varial - Stellar Wallet")} -
-
- -
-
-
- -
- - - - {t( - "To access your wallet, click Varial from your browser Extensions browser menu.", - )} - -
- } - > -
-
-
- -
-
- {t( - "Always check the domain of websites you’re using Varial with", - )} -
-
-
-
- -
-
- {t( - "Varial cannot recover your account if you lose your recovery phrase, so keep it safe", - )} -
-
-
- - - -
- - ); -}; diff --git a/extension/src/popup/views/FullscreenSuccessMessage/styles.scss b/extension/src/popup/views/FullscreenSuccessMessage/styles.scss deleted file mode 100644 index c4e4ec9e90..0000000000 --- a/extension/src/popup/views/FullscreenSuccessMessage/styles.scss +++ /dev/null @@ -1,180 +0,0 @@ -@use "../../styles/utils.scss" as *; - -.InfoBlock { - &__header { - color: var(--sds-clr-gray-12); - display: inline-block; - font-weight: var(--font-weight-bold); - font-size: 1.1rem; - padding-left: 0.65rem; - margin: 0; - } - - &__content { - text-align: left; - } - - ul { - list-style-type: disc; - } - - li { - margin-top: 1rem; - line-height: 1.5rem; - } - - li::before { - content: none !important; - } -} - -.FullscreenSuccessMessage { - display: flex; - height: 100%; - align-items: center; - - &__pin { - background: var(--sds-clr-gray-03); - border-radius: #{pxToRem(12px)}; - display: flex; - flex-direction: column; - gap: #{pxToRem(16px)}; - padding: #{pxToRem(16px)}; - position: absolute; - z-index: var(--z-index-tooltip); - width: #{pxToRem(336px)}; - top: #{pxToRem(24px)}; - right: #{pxToRem(24px)}; - - &__row { - display: flex; - line-height: #{pxToRem(24px)}; - - &:nth-child(2) { - gap: #{pxToRem(8px)}; - } - - &:nth-child(3) { - border-radius: #{pxToRem(8px)}; - align-items: center; - background: var(--sds-clr-gray-04); - gap: #{pxToRem(12px)}; - padding: #{pxToRem(8px)}; - } - - &__gradient { - background: linear-gradient( - 90deg, - var(--sds-clr-gray-03) 0%, - var(--sds-clr-gray-04) 49.96% - ); - border-top-right-radius: #{pxToRem(16px)}; - border-bottom-right-radius: #{pxToRem(16px)}; - width: #{pxToRem(264px)}; - height: #{pxToRem(32px)}; - } - - &__toolbar { - align-items: center; - display: flex; - border: 1px solid var(--sds-clr-gray-06); - border-radius: #{pxToRem(16px)}; - justify-content: center; - width: #{pxToRem(32px)}; - height: #{pxToRem(32px)}; - - svg { - width: #{pxToRem(20px)}; - height: #{pxToRem(20px)}; - } - } - - &__logo { - display: flex; - align-items: center; - svg { - width: #{pxToRem(24px)}; - height: #{pxToRem(24px)}; - } - } - - &__text { - flex: 1 0 0; - } - - &__pin-icon { - display: flex; - - svg { - fill: var(--sds-clr-navy-09); - stroke: var(--sds-clr-navy-09); - } - } - } - } - - &__text { - color: var(--sds-clr-gray-12); - } - &__wrapper { - display: flex; - flex-flow: column wrap; - align-items: center; - justify-content: center; - margin-top: 4.5rem; - width: 100%; - } - - &__content-wrapper { - display: flex; - flex-direction: column; - justify-content: start; - padding: 0 0 2rem 0; - color: var(--sds-clr-gray-12); - max-width: 31rem; - } - - &__infoBlock { - display: flex; - flex-direction: column; - gap: #{pxToRem(12px)}; - - &__row { - display: flex; - gap: #{pxToRem(12px)}; - - &__icon { - align-items: center; - background: var(--sds-clr-gray-01); - border-radius: #{pxToRem(100px)}; - border: 1px solid var(--sds-clr-gray-06); - display: flex; - height: #{pxToRem(24px)}; - justify-content: center; - padding: #{pxToRem(4px)}; - width: #{pxToRem(24px)}; - - svg { - width: #{pxToRem(16px)}; - } - } - - &__text { - color: var(--sds-clr-gray-11); - line-height: pxToRem(24px); - } - - &:nth-child(1) { - svg { - stroke: var(--sds-clr-mint-09); - } - } - - &:nth-child(2) { - svg { - stroke: var(--sds-clr-lime-09); - } - } - } - } -} diff --git a/extension/src/popup/views/RecoverAccount/index.tsx b/extension/src/popup/views/RecoverAccount/index.tsx index fa727b26b0..5b1e0e4cf1 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,118 @@ 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 { OnboardingStatus } from "popup/components/OnboardingStatus"; import { useRecoverAccountData } from "./hooks/useGetRecoverAccountData"; -interface PhraseInputProps { - phraseInput: string; - index: number; - handleMnemonicInputChange: (value: string, index: number) => void; - isTextShowing: boolean; - isLongPhrase: boolean; - handlePaste: (e: React.ClipboardEvent) => void; - pastedValue?: string; -} +// Shortest time "Importing your wallet…" stays up. A secret key imports +// almost instantly (no account discovery like a phrase), which would +// otherwise show the screen as a flash. +export const MIN_IMPORTING_MS = 500; -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". - 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. "ready" is the end of onboarding; the user closes the tab. -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 [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); + // The user can leave the route while an import is pending; nothing after + // its awaits may touch the UI or history once this view is gone. + const isMounted = useRef(false); + const { state, fetchData } = useRecoverAccountData(); - const publicKeyRef = useRef( - state.state === RequestState.SUCCESS && state.data.account.publicKey, - ); + // 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; - 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]); + let step: Step = + isPasswordEntry && hasConfirmedInput && secretInput ? "password" : "input"; + if (phase !== "form") { + step = phase; + } 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); - - if (PHRASE_LENGTH === SHORT_PHRASE) { - // when going back to 12 words, clear all the fields - setMnemonicPhraseArr([]); + isMounted.current = true; + // 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 }); } - }, [isLongPhrase]); - - useEffect(() => { - const getData = async () => { - await fetchData(); + fetchData().catch(() => { + // The hook keeps the error state; it's rendered below. + }); + return () => { + isMounted.current = false; + dispatch(clearApiError()); }; - getData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // One element for both states so the screen stays put and only the copy + // transitions. + if (step === "importing" || step === "ready") { + return ; + } + if ( state.state === RequestState.IDLE || state.state === RequestState.LOADING @@ -193,155 +162,211 @@ 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 { + // Started alongside the request, so it only adds time when the import + // finishes early. + const minimumWait = new Promise((resolve) => { + setTimeout(resolve, MIN_IMPORTING_MS); + }); + 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, + }), + ); + await minimumWait; + if (!isMounted.current) { + return; + } + + if ( + recoverAccount.fulfilled.match(res) || + recoverAccountFromSecretKey.fulfilled.match(res) + ) { + setSecretInput(""); + try { + await fetchData(); + } catch (e) { + // The account is stored; the popup loads its own data when opened. + } + if (!isMounted.current) { + return; + } + setPhase("ready"); + 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", + )} + +
+
+