From 6cb7326b99b773195acd4392eb303e41efb01256 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 11 Nov 2026 11:07:46 -0700 Subject: [PATCH 1/5] feat(accounts): import an additional recovery phrase without wiping the wallet Add Import Recovery Phrase to the Add wallet chooser. The new importRecoveryPhrase handler validates the phrase, checks the password, refuses a phrase or account the wallet already holds, adds index 0 plus any funded indices up to 49, and makes index 0 active. Every refusal returns before anything is written. Move the password check and funded-index discovery into shared helpers. addRecoveryPhraseSource and recoverAccount now use them. --- .../api/helpers/__tests__/mnemonic.test.ts | 39 ++ @shared/api/helpers/mnemonic.ts | 18 + @shared/api/internal.ts | 45 ++ @shared/api/types/message-request.ts | 12 + @shared/constants/services.ts | 1 + ...11-11-import-additional-recovery-phrase.md | 445 ++++++++++++++++++ ...mport-additional-recovery-phrase-design.md | 223 +++++++++ extension/e2e-tests/addWallet.test.ts | 79 ++++ extension/e2e-tests/helpers/addWallet.ts | 22 + .../__tests__/discoverFundedIndices.test.ts | 157 ++++++ .../__tests__/importRecoveryPhrase.test.ts | 437 +++++++++++++++++ .../__tests__/verifyAddPassword.test.ts | 100 ++++ .../handlers/addRecoveryPhraseSource.ts | 65 +-- .../handlers/importRecoveryPhrase.ts | 199 ++++++++ .../handlers/recoverAccount.ts | 68 ++- .../helpers/discover-funded-indices.ts | 60 +++ .../helpers/verify-add-password.ts | 75 +++ .../messageListener/popupMessageListener.ts | 12 + extension/src/popup/Router.tsx | 5 + .../popup/components/EnterPassword/index.tsx | 9 +- extension/src/popup/constants/routes.ts | 1 + extension/src/popup/ducks/accountServices.ts | 28 ++ .../src/popup/locales/en/translation.json | 3 + .../src/popup/locales/pt/translation.json | 3 + extension/src/popup/metrics/views.ts | 4 + extension/src/popup/views/AddWallet/index.tsx | 13 +- .../views/ImportRecoveryPhrase/index.tsx | 193 ++++++++ .../views/ImportRecoveryPhrase/styles.scss | 11 + .../views/__tests__/CreateWallet.test.tsx | 4 +- .../__tests__/ImportRecoveryPhrase.test.tsx | 247 ++++++++++ 30 files changed, 2485 insertions(+), 93 deletions(-) create mode 100644 @shared/api/helpers/__tests__/mnemonic.test.ts create mode 100644 @shared/api/helpers/mnemonic.ts create mode 100644 docs/superpowers/plans/2026-11-11-import-additional-recovery-phrase.md create mode 100644 docs/superpowers/specs/2026-11-11-import-additional-recovery-phrase-design.md create mode 100644 extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts create mode 100644 extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts create mode 100644 extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts create mode 100644 extension/src/background/messageListener/handlers/importRecoveryPhrase.ts create mode 100644 extension/src/background/messageListener/helpers/discover-funded-indices.ts create mode 100644 extension/src/background/messageListener/helpers/verify-add-password.ts create mode 100644 extension/src/popup/views/ImportRecoveryPhrase/index.tsx create mode 100644 extension/src/popup/views/ImportRecoveryPhrase/styles.scss create mode 100644 extension/src/popup/views/__tests__/ImportRecoveryPhrase.test.tsx diff --git a/@shared/api/helpers/__tests__/mnemonic.test.ts b/@shared/api/helpers/__tests__/mnemonic.test.ts new file mode 100644 index 0000000000..92db169686 --- /dev/null +++ b/@shared/api/helpers/__tests__/mnemonic.test.ts @@ -0,0 +1,39 @@ +import { Buffer } from "buffer"; +import { entropyToMnemonic, validateMnemonic, wordlists } from "bip39"; +import { isValidImportMnemonic, normalizeMnemonic } from "../mnemonic"; + +describe("normalizeMnemonic", () => { + it("trims, lowercases and collapses whitespace", () => { + expect(normalizeMnemonic(" Abandon\n abandon\tABOUT ")).toBe( + "abandon abandon about", + ); + }); +}); + +describe("isValidImportMnemonic", () => { + const twelve = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const twentyFour = `${"abandon ".repeat(23)}art`; + it("accepts a valid 12-word phrase", () => + expect(isValidImportMnemonic(twelve)).toBe(true)); + it("accepts a valid 24-word phrase", () => + expect(isValidImportMnemonic(twentyFour)).toBe(true)); + it("accepts a messy valid phrase", () => + expect(isValidImportMnemonic(`\n${twelve.toUpperCase()} `)).toBe(true)); + it("refuses 15 words even if the checksum passes", () => { + const fifteen = entropyToMnemonic(Buffer.alloc(20)); + expect(fifteen.split(" ")).toHaveLength(15); + expect(validateMnemonic(fifteen, wordlists.english)).toBe(true); + expect(isValidImportMnemonic(fifteen)).toBe(false); + }); + it("refuses a bad checksum", () => + expect(isValidImportMnemonic(twelve.replace(/about$/, "abandon"))).toBe( + false, + )); + it("refuses a word not in the list", () => + expect(isValidImportMnemonic(twelve.replace(/^abandon/, "tomato1"))).toBe( + false, + )); + it("refuses an empty string", () => + expect(isValidImportMnemonic("")).toBe(false)); +}); diff --git a/@shared/api/helpers/mnemonic.ts b/@shared/api/helpers/mnemonic.ts new file mode 100644 index 0000000000..6d98d73b54 --- /dev/null +++ b/@shared/api/helpers/mnemonic.ts @@ -0,0 +1,18 @@ +import { validateMnemonic, wordlists } from "bip39"; + +/** Trim, lowercase and collapse whitespace runs to a single space. */ +export const normalizeMnemonic = (phrase: string): string => + phrase.trim().toLowerCase().split(/\s+/).filter(Boolean).join(" "); + +/** + * True when the phrase, once normalized, is 12 or 24 words and passes the + * BIP-39 English wordlist and checksum. + */ +export const isValidImportMnemonic = (phrase: string): boolean => { + const normalized = normalizeMnemonic(phrase); + const wordCount = normalized === "" ? 0 : normalized.split(" ").length; + if (wordCount !== 12 && wordCount !== 24) { + return false; + } + return validateMnemonic(normalized, wordlists.english); +}; diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index ba437e0f95..b64a8a27dd 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -311,6 +311,51 @@ export const addRecoveryPhraseSource = async ({ return result; }; +/** Imports a further recovery phrase; see `ImportRecoveryPhraseMessage`. */ +export const importRecoveryPhrase = async ({ + activePublicKey, + password, + mnemonicPhrase, +}: { + activePublicKey: string; + password: string; + mnemonicPhrase: string; +}): Promise<{ + publicKey: string; + activeAccountId: string; + allAccounts: Array; + hasPrivateKey: boolean; + secretSources: SecretSource[]; +}> => { + let response = { + error: "", + publicKey: "", + activeAccountId: "", + allAccounts: [] as Array, + hasPrivateKey: false, + secretSources: [] as SecretSource[], + }; + + try { + response = await sendMessageToBackground({ + activePublicKey, + password, + mnemonicPhrase, + type: SERVICE_TYPES.IMPORT_RECOVERY_PHRASE, + }); + } catch (e) { + console.error(e); + throw e; + } + + const { error, ...result } = response; + if (error) { + throw new Error(error); + } + + return result; +}; + export const importAccount = async ({ password, privateKey, diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 3d9372761b..2f61844b9d 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -150,6 +150,17 @@ export interface AddRecoveryPhraseSourceMessage extends BaseMessage { pendingPhraseId: string; } +/** + * Adds a recovery phrase the user typed as a new phrase source, with its + * index 0 and every funded index up to the discovery depth. Nothing already + * in the wallet changes. + */ +export interface ImportRecoveryPhraseMessage extends BaseMessage { + type: SERVICE_TYPES.IMPORT_RECOVERY_PHRASE; + password: string; + mnemonicPhrase: string; +} + export interface ImportAccountMessage extends BaseMessage { type: SERVICE_TYPES.IMPORT_ACCOUNT; password: string; @@ -588,6 +599,7 @@ export type ServiceMessageRequest = | AddAccountMessage | GenerateRecoveryPhraseMessage | AddRecoveryPhraseSourceMessage + | ImportRecoveryPhraseMessage | ImportAccountMessage | ImportHardWareWalletMessage | MakeAccountActiveMessage diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 8d44b20719..6597487b44 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -5,6 +5,7 @@ export enum SERVICE_TYPES { IMPORT_ACCOUNT = "IMPORT_ACCOUNT", GENERATE_RECOVERY_PHRASE = "GENERATE_RECOVERY_PHRASE", ADD_RECOVERY_PHRASE_SOURCE = "ADD_RECOVERY_PHRASE_SOURCE", + IMPORT_RECOVERY_PHRASE = "IMPORT_RECOVERY_PHRASE", IMPORT_HARDWARE_WALLET = "IMPORT_HARDWARE_WALLET", LOAD_ACCOUNT = "LOAD_ACCOUNT", MAKE_ACCOUNT_ACTIVE = "MAKE_ACCOUNT_ACTIVE", diff --git a/docs/superpowers/plans/2026-11-11-import-additional-recovery-phrase.md b/docs/superpowers/plans/2026-11-11-import-additional-recovery-phrase.md new file mode 100644 index 0000000000..6717b271d7 --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-import-additional-recovery-phrase.md @@ -0,0 +1,445 @@ +# Import an Additional Recovery Phrase Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a user add a recovery phrase to an existing wallet from the Add +wallet chooser without touching anything already in it. + +**Architecture:** A new background handler, `importRecoveryPhrase`, does every +check before any write, then registers the source and stores index 0 plus the +funded indices found on mainnet. Two helpers come out of +`addRecoveryPhraseSource` and onboarding recovery so the new handler doesn't +copy them: a password check and a discovery loop. The popup gets a two-step view +(phrase, then `EnterPassword`) reached from a new chooser entry. + +**Tech Stack:** TypeScript, React, Redux Toolkit, Formik, `stellar-hd-wallet`, +`bip39`, Jest, Playwright. + +**Spec:** +`docs/superpowers/specs/2026-11-11-import-additional-recovery-phrase-design.md` + +## Global Constraints + +- Never commit, push, stash or merge. The user drives git history. Where a task + ends, stop and report; there is no commit step. +- Discovery depth is 50: index 0 is always added, indices 1 to 49 are checked on + `MAINNET_NETWORK_DETAILS.networkUrl`. Onboarding's + `numOfPublicKeysToCheck = 5` does not change. +- An account counts as funded when the Horizon `/accounts/` JSON has + `account_id`. A lookup that throws counts as not found and is reported with + `captureException`. +- Accept only 12- or 24-word phrases that pass the BIP-39 English wordlist and + checksum. Normalize first: trim, lowercase, collapse whitespace runs to one + space. +- Every refusal returns before the first write. Writes run in this order: + `addSecretSource`, then `storeAccount` per index ascending, then + `activatePublicKey` for index 0, then `void linkWalletSources(...)`. +- The handler never calls `reset()`, `clearSession`, any `clear*` helper or + `removePreviousAccount`, and never writes `APPLICATION_ID` or + `REBRAND_ANNOUNCEMENT_STATUS_ID`. +- Error strings, unchanged: "Invalid mnemonic phrase", "Incorrect password", + "Unable to login", `PHRASE_ALREADY_HELD_ERROR`, `ACCOUNT_ALREADY_EXISTS_ERROR` + (popup shows "This account is already in your wallet."), "Error adding + recovery phrase", `NO_SOFTWARE_KEYSTORE_ERROR`. +- Chooser copy: title "Import Recovery Phrase", description "Import wallets + using a recovery phrase." Screen copy: title "Import Recovery Phrase", + subtitle "Restore your wallet with the 12 or 24-word recovery phrase", + placeholder "Recovery phrase", button "Import". No Paste button (extension + pages cannot read the clipboard without `clipboardRead`). +- All user-facing strings go through `t()`. Run + `yarn build:extension:translations` after adding them. +- Messages use `SERVICE_TYPES` enum members, never raw strings. +- Run Jest from the repo root as `yarn jest `. Typecheck with + `npx tsc --noEmit -p extension/tsconfig.json`; the build doesn't typecheck. + +## Review Focus + +1. A pasted phrase with newlines, double spaces or capitals should be accepted + and should produce the same source id as the clean phrase. Pinned in Task 1 + (`normalizeMnemonic`) and Task 3 (handler accepts a messy phrase). +2. A phrase whose index 0 was earlier imported as a secret key should be refused + as an existing account, with no source registered. Pinned in Task 3. +3. Importing while a hardware account is active should check the password + against the software keystore, not fail. Pinned in Task 2 (helper test with a + hardware wallet active). +4. A wrong password on a locked session should return "Unable to login" with + nothing written. Pinned in Task 3. +5. Going back from the password step should keep the phrase in the textarea, and + leaving the flow should clear it. Pinned in Task 4. + +--- + +### Task 1: Phrase normalization and validation helpers + +**Files:** + +- Create: `@shared/api/helpers/mnemonic.ts` +- Test: `@shared/api/helpers/__tests__/mnemonic.test.ts` + +**Interfaces:** + +- Produces: `normalizeMnemonic(phrase: string): string` and + `isValidImportMnemonic(phrase: string): boolean`. The second normalizes, + requires 12 or 24 words, and calls + `validateMnemonic(normalized, wordlists.english)` from `bip39`, as + `deriveAuthKeypair.ts` does. + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("normalizeMnemonic", () => { + it("trims, lowercases and collapses whitespace", () => { + expect(normalizeMnemonic(" Abandon\n abandon\tABOUT ")).toBe( + "abandon abandon about", + ); + }); +}); + +describe("isValidImportMnemonic", () => { + const twelve = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + const twentyFour = `${"abandon ".repeat(23)}art`; + it("accepts a valid 12-word phrase", () => + expect(isValidImportMnemonic(twelve)).toBe(true)); + it("accepts a valid 24-word phrase", () => + expect(isValidImportMnemonic(twentyFour)).toBe(true)); + it("accepts a messy valid phrase", () => + expect(isValidImportMnemonic(`\n${twelve.toUpperCase()} `)).toBe(true)); + it("refuses 15 words even if the checksum passes", () => { + const fifteen = "abandon ".repeat(14) + "address"; + expect(isValidImportMnemonic(fifteen)).toBe(false); + }); + it("refuses a bad checksum", () => + expect(isValidImportMnemonic(twelve.replace(/about$/, "abandon"))).toBe( + false, + )); + it("refuses a word not in the list", () => + expect(isValidImportMnemonic(twelve.replace(/^abandon/, "tomato1"))).toBe( + false, + )); + it("refuses an empty string", () => + expect(isValidImportMnemonic("")).toBe(false)); +}); +``` + +- [ ] **Step 2: Run them and see them fail** + +Run: `yarn jest @shared/api/helpers/__tests__/mnemonic.test.ts` Expected: FAIL, +module not found. + +- [ ] **Step 3: Implement both functions in `@shared/api/helpers/mnemonic.ts`** + +- [ ] **Step 4: Run them and see them pass** + +Run: `yarn jest @shared/api/helpers/__tests__/mnemonic.test.ts` Expected: PASS, +8 tests. + +--- + +### Task 2: Extract the password check and the discovery loop + +Both helpers come out of existing code. The existing `addRecoveryPhraseSource` +and `recoverAccount` suites are the regression guard for the extraction. + +**Files:** + +- Create: + `extension/src/background/messageListener/helpers/verify-add-password.ts` +- Create: + `extension/src/background/messageListener/helpers/discover-funded-indices.ts` +- Modify: + `extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts` + (replace the keyID, `loginToAllAccounts` and `unlockKeystore` block with the + helper) +- Modify: `extension/src/background/messageListener/handlers/recoverAccount.ts` + (replace the preload loop with the helper; keep its own `storeAccount` calls) +- Test: + `extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts` +- Test: + `extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts` + +**Interfaces:** + +- Produces: + `verifyAddPassword({ password, localStore, sessionStore, keyManager, sessionTimer }): Promise`. + It returns `null` when the password unlocks the software keystore, or one of + `NO_SOFTWARE_KEYSTORE_ERROR`, "Unable to login" or "Incorrect password", with + the same logic and Sentry message shape as the block it replaces. Move + `NO_SOFTWARE_KEYSTORE_ERROR` into this file and re-export it from + `addRecoveryPhraseSource.ts` so existing imports still work. +- Produces: `discoverFundedIndices({ wallet, from, to }): Promise`, + where `wallet` is the `fromMnemonic` result. It checks indices `from` to `to` + inclusive on mainnet and returns the funded ones in ascending order. + Concurrency is the implementer's call. `recoverAccount` calls it with + `from: 1, to: numOfPublicKeysToCheck`. + +- [ ] **Step 1: Write the failing helper tests** + +`verifyAddPassword`: copy the store setup from +`addRecoveryPhraseSource.test.ts`. Tests: + +- `returns null for the right password` on an unlocked session +- `returns "Incorrect password" for a wrong password on an unlocked session` +- `returns "Unable to login" for a wrong password on a locked session`, with no + active hash key +- `returns NO_SOFTWARE_KEYSTORE_ERROR when there is no KEY_ID` +- `checks the software keystore while a hardware account is active` (Review + Focus 3). Set the hardware account active so `getIsHardwareWalletActive` is + true, then expect `null` for the right password. + +`discoverFundedIndices`: mock `global.fetch` per URL, keyed by +`wallet.getPublicKey(i)`. Tests: + +- `returns funded indices in ascending order`. Fund 7, 2 and 49 out of 1..49; + resolve them out of order with staggered timers; expect `[2, 7, 49]`. +- `treats a 404 body as not funded`, for `{ status: 404 }` with no `account_id`. +- `skips an index whose lookup throws and reports it`. Reject index 3, fund + index 4, expect `[4]` and `captureException` called once. +- `queries mainnet Horizon`. Every fetched URL starts with + `${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/`. + +- [ ] **Step 2: Run them and see them fail** + +Run: +`yarn jest extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts` +Expected: FAIL, modules not found. + +- [ ] **Step 3: Implement both helpers and switch the two handlers over to + them** + +- [ ] **Step 4: Run the new and existing suites** + +Run: +`yarn jest extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts extension/src/background/messageListener/__tests__/addRecoveryPhraseSource.test.ts extension/src/background/messageListener/__tests__/recoverAccount.test.ts` +Expected: PASS, with no existing test edited. + +--- + +### Task 3: The `importRecoveryPhrase` handler + +**Files:** + +- Create: + `extension/src/background/messageListener/handlers/importRecoveryPhrase.ts` +- Modify: `@shared/constants/services.ts` (add + `IMPORT_RECOVERY_PHRASE = "IMPORT_RECOVERY_PHRASE"` beside + `ADD_RECOVERY_PHRASE_SOURCE`) +- Modify: `@shared/api/types/message-request.ts` (add + `ImportRecoveryPhraseMessage`, add it to the request union near line 590) +- Modify: `extension/src/background/messageListener/popupMessageListener.ts` + (add `const phraseImportDiscoveryDepth = 50;` beside `numOfPublicKeysToCheck`, + plus a `case SERVICE_TYPES.IMPORT_RECOVERY_PHRASE` beside the + `ADD_RECOVERY_PHRASE_SOURCE` case) +- Test: + `extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts` + +**Interfaces:** + +- Consumes: `normalizeMnemonic` and `isValidImportMnemonic` (Task 1); + `verifyAddPassword` and `discoverFundedIndices` (Task 2); + `PHRASE_ALREADY_HELD_ERROR` from `addRecoveryPhraseSource.ts`; + `ACCOUNT_ALREADY_EXISTS_ERROR` from `@shared/constants/errors`. +- Produces: + `interface ImportRecoveryPhraseMessage extends BaseMessage { type: SERVICE_TYPES.IMPORT_RECOVERY_PHRASE; password: string; mnemonicPhrase: string }`. +- Produces: + `importRecoveryPhrase({ request, localStore, sessionStore, keyManager, sessionTimer, discoveryDepth }): Promise<{ error: string } | { publicKey; activeAccountId; allAccounts; hasPrivateKey; secretSources }>`. + The success shape is the same as `addRecoveryPhraseSource`'s. +- Produces: + `export const IMPORT_PHRASE_FAILED_ERROR = "Error adding recovery phrase";`. + +Order inside the handler: normalize; validate ("Invalid mnemonic phrase"); +`verifyAddPassword`; held-source check; +`discoverFundedIndices({ wallet, from: 1, to: discoveryDepth - 1 })`; check that +none of `[0, ...funded]` is already in `allAccountsSelector`; then the writes +from Global Constraints. If a write throws, call `captureException` and return +`IMPORT_PHRASE_FAILED_ERROR`. + +- [ ] **Step 1: Write the failing tests** + +Set up a wallet with one phrase source (Seed Phrase 1) and two accounts, using +the same fixture approach as `addRecoveryPhraseSource.test.ts`. Mock +`link-sources`. Mock `fetch` so chosen addresses of the second phrase are +funded. Capture a snapshot of the first source's `stellarkeys:*` entries, +`KEY_ID_LIST` prefix, account names, derivation indices and provenance before +each import. Tests: + +- `adds the second phrase as Seed Phrase 2 with index 0 and its funded indices`. + Fund 3 and 10; expect three new accounts, at derivation indices `[0, 3, 10]`, + all with the new `sourceId`, none `imported`. +- `leaves the first phrase's keystores, names, indices and provenance unchanged`. + Compare against the snapshot. +- `adds only index 0 when nothing is funded`. +- `makes the new phrase's index 0 active`, even though index 10 was stored last. +- `accepts a phrase with capitals, newlines and extra spaces and registers the clean phrase's source id` + (Review Focus 1). +- `refuses an invalid phrase with "Invalid mnemonic phrase"`, for both a 15-word + and a bad-checksum phrase, with nothing written. +- `refuses a phrase the wallet already holds with PHRASE_ALREADY_HELD_ERROR and makes no Horizon request`. +- `refuses the whole import when a funded index is already in the wallet`. + Import the second phrase's index 3 as a secret key first, fund 3 and 10, + expect `ACCOUNT_ALREADY_EXISTS_ERROR`, no new source, and no new keystores. +- `refuses when index 0 was earlier imported as a secret key` (Review Focus 2). +- `refuses a wrong password on a locked session with "Unable to login" and writes nothing` + (Review Focus 4). +- `leaves the source registered when a storeAccount fails partway, and a retry is refused as already held`. + Make the second `storeAccount` throw; expect `IMPORT_PHRASE_FAILED_ERROR`, the + source present, then a second call returning `PHRASE_ALREADY_HELD_ERROR`. +- `does not touch application state or the rebrand announcement status`. + +"Writes nothing" means `KEY_ID_LIST`, the secret sources, the account names and +`KEY_ID` all equal their pre-call values. + +- [ ] **Step 2: Run them and see them fail** + +Run: +`yarn jest extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts` +Expected: FAIL, module not found. + +- [ ] **Step 3: Add the enum member and the message type, implement the handler, + and wire the listener case with + `discoveryDepth: phraseImportDiscoveryDepth`** + +- [ ] **Step 4: Run the suite and the typecheck** + +Run: +`yarn jest extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts && npx tsc --noEmit -p extension/tsconfig.json` +Expected: PASS, and tsc exits 0. + +--- + +### Task 4: Popup API, thunk, view, route and chooser entry + +**Files:** + +- Modify: `@shared/api/internal.ts` (add `importRecoveryPhrase` beside + `addRecoveryPhraseSource`) +- Modify: `extension/src/popup/ducks/accountServices.ts` (add an + `importRecoveryPhrase` thunk; register `.fulfilled` with `onAccountAdded` and + `.rejected` with `onAccountAddFailed`, as at line 814) +- Modify: `extension/src/popup/constants/routes.ts` (add + `importRecoveryPhrase = "/add-wallet/import-phrase"`) +- Modify: `extension/src/popup/Router.tsx` (add the route beside + `ROUTES.createWallet`) +- Modify: `extension/src/popup/views/AddWallet/index.tsx` (insert the second + action, update the header comment) +- Create: `extension/src/popup/views/ImportRecoveryPhrase/index.tsx` and + `styles.scss` +- Test: `extension/src/popup/views/__tests__/ImportRecoveryPhrase.test.tsx` + +**Interfaces:** + +- Consumes: `SERVICE_TYPES.IMPORT_RECOVERY_PHRASE` and the response shape (Task + 3); `isValidImportMnemonic` and `normalizeMnemonic` (Task 1). +- Produces: + `importRecoveryPhrase({ activePublicKey, password, mnemonicPhrase })` in + `internal.ts`, which throws on `response.error` as `addRecoveryPhraseSource` + does. The thunk is named `"auth/importRecoveryPhrase"` and takes + `{ password, mnemonicPhrase }`. +- Produces: chooser action + `{ icon: , title: t("Import Recovery Phrase"), description: t("Import wallets using a recovery phrase."), link: ROUTES.importRecoveryPhrase, testId: "add-wallet-import-phrase" }`, + placed second. +- Produces test ids: `import-phrase-input` (textarea), `import-phrase-submit`. + The password step uses `EnterPassword` with + `confirmButtonTitle={t("Import")}`, following the `CreateWallet` step pattern + (`step: "phrase" | "password"`). + +View behavior: + +- Import is disabled while the trimmed textarea is empty. On Import, if + `!isValidImportMnemonic(value)`, the textarea shows + `t("Invalid mnemonic phrase")` and the view stays put. Otherwise it moves to + the password step. +- A keyboard paste into the textarea is followed by + `navigator.clipboard.writeText("")`, ignoring a failure. +- On the password step the back action calls `clearApiError()` and returns to + the phrase step with the text kept. Leaving the flow (the header back on the + phrase step goes to `ROUTES.addWallet`) and success both clear the phrase. +- On fulfilment, + `emitMetric(METRIC_NAMES.accountCreated, { number_of_accounts, source: "imported_phrase" })` + runs, then `navigateTo(ROUTES.account, navigate)`. +- The password step maps `ACCOUNT_ALREADY_EXISTS_ERROR` to + `t("This account is already in your wallet.")`, the same way `ImportAccount` + does. Other errors show as they come. + +- [ ] **Step 1: Write the failing view tests** + +Follow `CreateWallet.test.tsx` for store setup and mocking `internal`. Tests: + +- `disables Import while the phrase is empty` +- `shows "Invalid mnemonic phrase" for a 15-word phrase and stays on the phrase step` +- `moves a valid phrase to the password step` +- `sends the normalized phrase and the password, then navigates to the account view`. + Assert `importRecoveryPhrase` was called with `mnemonicPhrase` equal to the + normalized text. +- `shows "This recovery phrase is already in your wallet" on the password step when refused` +- `shows "This account is already in your wallet." for ACCOUNT_ALREADY_EXISTS_ERROR` +- `keeps the phrase when going back from the password step` (Review Focus 5) +- `clears the clipboard after a keyboard paste` +- Add to the existing AddWallet chooser test, or create + `views/__tests__/AddWallet.test.tsx` if none exists: + `lists Import Recovery Phrase second and links to the import route` + +- [ ] **Step 2: Run them and see them fail** + +Run: +`yarn jest extension/src/popup/views/__tests__/ImportRecoveryPhrase.test.tsx` +Expected: FAIL, module not found. + +- [ ] **Step 3: Implement the API call, thunk, route, view and chooser entry** + +- [ ] **Step 4: Regenerate translations, run the tests and typecheck** + +Run: +`yarn build:extension:translations && yarn jest extension/src/popup/views/__tests__ && npx tsc --noEmit -p extension/tsconfig.json` +Expected: the new keys appear in +`extension/src/popup/locales/*/translation.json`, the tests pass and tsc +exits 0. + +--- + +### Task 5: End-to-end coverage + +**Files:** + +- Modify: `extension/e2e-tests/helpers/addWallet.ts` (add + `importRecoveryPhraseSource({ page, password, words })`) +- Modify: `extension/e2e-tests/addWallet.test.ts` + +**Interfaces:** + +- Consumes: the test ids from Task 4. +- Produces: + `importRecoveryPhraseSource({ page, password, words }: { page: Page; password: string; words: string[] }): Promise`. + It goes from an open account sheet through `add-wallet`, + `add-wallet-import-phrase`, filling `import-phrase-input` with + `words.join(" ")`, `import-phrase-submit`, `#password-input`, and the `Import` + button. + +The background's Horizon lookups go to real mainnet. Generate the second phrase +in the test with `generateMnemonic()` from `bip39`, so its addresses are +unfunded and only index 0 gets added. + +- [ ] **Step 1: Write the tests, using the group helpers already in + `addWallet.test.ts`** +- `Imports a second recovery phrase without changing the first`. Record the + "Recovery phrase 1" rows, import a fresh phrase, then expect groups "Recovery + phrase 1" and "Recovery phrase 2", the first group's rows unchanged, and + "Recovery phrase 2" holding one row. +- `Refuses a recovery phrase the wallet already holds`. Import the same fresh + phrase a second time and expect the text "This recovery phrase is already in + your wallet" with the groups unchanged. + +- [ ] **Step 2: Build and run them** + +Run: `yarn build:extension && yarn test:e2e e2e-tests/addWallet.test.ts` on Node +22.12.0. Expected: both new tests pass, and the existing ones in the file still +pass. + +- [ ] **Step 3: Run the full unit suite** + +Run: `yarn test:ci --testPathIgnorePatterns '.claude/worktrees'` Expected: only +the known local failures (the `.superpowers` scratch suites and +`testNodeCompat`), and nothing new. diff --git a/docs/superpowers/specs/2026-11-11-import-additional-recovery-phrase-design.md b/docs/superpowers/specs/2026-11-11-import-additional-recovery-phrase-design.md new file mode 100644 index 0000000000..25c6fc6e18 --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-import-additional-recovery-phrase-design.md @@ -0,0 +1,223 @@ +# Import an additional recovery phrase + +Issue: stellar/freighter-private#55. Branch: +`feature/import-additional-recovery-phrase`, cut from +`feature/add-wallet-chooser`. + +## Intent + +A user who already has a wallet can bring in a second (or third) recovery phrase +from the Add wallet chooser. The phrase becomes a new source with its own group +in the account switcher, and nothing already in the wallet changes. + +Today the only phrase import is onboarding's `recoverAccount`. It deletes every +`stellarkeys:*` keystore, resets `keyIdList`, and clears sources, derivation +indices, provenance and link status before storing the recovered phrase. Running +it on a live wallet destroys the first wallet. That flow stays destructive on +purpose, because it is also the "forgot password" path. This work adds a +separate, additive path. + +## Goals + +- A fourth option in the Add wallet chooser, "Import Recovery Phrase", with the + description "Import wallets using a recovery phrase." It sits second, between + "Create new wallet" and "Import Secret Key", matching the mock order. The + comment at the top of `popup/views/AddWallet/index.tsx` saying phrase import + will join the list later gets updated. +- Importing adds a phrase source with the next label in sequence and adds that + phrase's accounts. Existing sources, accounts, names, derivation indices, + provenance, link status and app settings are untouched. +- Discovery follows onboarding: index 0 is always added, and each further index + up to the discovery depth is added if it exists on mainnet Horizon. +- Collisions are refused, never merged. + +## Non-goals + +- Converting or re-rooting a collision, such as turning a secret-key import into + an account under the phrase. +- Caps on sources or accounts. #77 was closed as not planned, so nothing limits + how many phrases or accounts a wallet holds. +- Telling the user how deep discovery goes, or that a funded account past it has + to be added by hand. The issue asked for this line, but the decision is to + follow onboarding, which says nothing. +- Changing onboarding recovery in any way. +- Serializing concurrent account-mutating handlers. That is #112. + +## Anti-goals + +- Any code path where a failed or refused import leaves the wallet partly + changed. Every refusal happens before the first write. +- Reusing `recoverAccount` with a flag. Its job is to start the wallet afresh; + mixing additive behavior into it puts the wipe one boolean away from a live + wallet. + +## The screens + +Both screens follow the Varial mocks for the Manage accounts section ("Import" +frames), adapted to the extension the same way the other add-wallet screens +were. + +1. **Import Recovery Phrase.** Back arrow to the chooser. Title "Import Recovery + Phrase", subtitle "Restore your wallet with the 12 or 24-word recovery + phrase". One multi-line textarea with the placeholder "Recovery phrase". A + full-width Import button, disabled while the textarea is empty. A keyboard + paste lands in the textarea, and the clipboard is cleared afterwards, as + onboarding recovery does today. The mock's Paste button was dropped: + extension pages can't read the clipboard without the `clipboardRead` + permission, and asking for that permission on the recovery-phrase screen was + rejected. +2. **Password.** The shared `EnterPassword` step, with a back action to the + phrase screen, the same way `CreateWallet` asks for the password before + calling `addRecoveryPhraseSource`. The phrase stays in component state + between the two steps and is cleared on success and on leaving the flow. + +On success the popup navigates to `ROUTES.account`, the same landing as the +other add paths. The new phrase's index 0 account is active, and its group shows +in the switcher under the new label. + +While the import runs (password check, discovery, writes), the password step's +confirm button shows its loading state and the user can't submit twice. + +## Validation and errors + +The phrase is normalized before checking: trimmed, lowercased, with any run of +whitespace collapsed to one space. + +The Import button runs the first check in the popup, so a bad phrase never +reaches the password step: + +- The word count must be 12 or 24, and the phrase must pass the BIP-39 wordlist + and checksum check. Otherwise the textarea shows "Invalid mnemonic phrase" and + the flow stays on the phrase screen. + +The background re-checks everything, in this order, and returns an error before +writing anything: + +1. Word count and BIP-39 validity, with the same error. +2. The password is checked against the hot keystore, as + `addRecoveryPhraseSource` does: log in to all accounts if there's no active + hash key, then unlock the software keystore. The errors are the same as that + handler's: a wrong password on a locked session fails the login and gives + "Unable to login", and a wrong password on an unlocked session gives + "Incorrect password". A wallet with no software keystore gives the existing + `NO_SOFTWARE_KEYSTORE_ERROR`. +3. If the phrase's source id (`getPhraseSourceId`) is already among the wallet's + secret sources, the import is refused with the existing + `PHRASE_ALREADY_HELD_ERROR`: "This recovery phrase is already in your + wallet". +4. Discovery runs (below). +5. If any address discovery would add, index 0 included, is already in + `allAccounts`, the whole import is refused with + `ACCOUNT_ALREADY_EXISTS_ERROR`. The popup shows "This account is already in + your wallet.", the same mapping `ImportAccount` uses. + +Errors from the background show on the password step through the usual +`authError` path. + +## Discovery + +The depth is **50**, from the issue. Index 0 is always added. For indices 1 to +49, the background fetches +`MAINNET_NETWORK_DETAILS.networkUrl/accounts/` and adds the account +if the response carries an `account_id`, which is the same test onboarding uses. +Mainnet is checked whatever network the user has selected, as onboarding does. + +A lookup that fails (network error, non-JSON body, rate limit) counts as not +found. It's reported to Sentry the way onboarding reports it, and it never fails +the import. + +The 50-lookup loop needs to finish in a reasonable time. How it does that is the +builder's call: bounded concurrency is fine, as long as the stored order stays +by ascending index. + +The new constant sits next to `numOfPublicKeysToCheck` in +`popupMessageListener.ts` and is passed in the same way. Onboarding's depth of 5 +stays as it is. + +## Writes + +Only after every check passes, and in this order: + +- `addSecretSource` with type `PHRASE`, which assigns the next phrase label. It + goes first so that if a later write fails, the accounts already stored sit + under a labeled group, and a retry gets the clear "already in your wallet" + refusal. The user can then add any missing account through "Add account to + existing wallet". (Call made in design review.) +- `storeAccount` for each account to add, in ascending index order, with + `mnemonicPhrase`, `phraseSourceId` and `sourceId` set to the new source id and + `derivationIndex` set to its index. No account name is passed, so each one + gets the next default name, exactly as onboarding's discovered accounts do. + None are flagged `imported`. +- Activate the index 0 account (`activatePublicKey`). `storeAccount` leaves the + last-stored account active, which would otherwise be the highest funded index. +- `linkWalletSources`, not awaited, as `addRecoveryPhraseSource` does. + +If a write throws partway through, report to Sentry and return "Error adding +recovery phrase", the same string `addRecoveryPhraseSource` uses. Rolling back +partial writes isn't in scope; the checks above are what make a refusal clean. + +The handler never calls `reset()`, `clearSession`, any `clear*` helper, or +`removePreviousAccount`, and never touches `APPLICATION_ID` or the rebrand +announcement status. + +The response has the same shape as `addRecoveryPhraseSource` returns +(`publicKey`, `activeAccountId`, `allAccounts`, `hasPrivateKey`, +`secretSources`), so the popup's account state updates the same way. + +## Plumbing + +- A new handler, `handlers/importRecoveryPhrase.ts`, registered in + `popupMessageListener.ts` under a new `SERVICE_TYPES` member, with a typed + request in `@shared/api/types/message-request.ts`. No raw string messages. +- A popup API call in `@shared/api/internal.ts` and a thunk in + `popup/ducks/accountServices.ts` next to `addRecoveryPhraseSource`, updating + the same state on fulfilment. +- A route constant for the import screen under the `/add-wallet/` prefix, wired + in `Router.tsx` with the other add-wallet routes. +- A metric on success, mirroring the `accountCreated` call in `CreateWallet`, + with `source: "imported_phrase"` and the new account count. +- New user-facing strings go through `t()`, and the translation keys get + regenerated. + +## Testing + +- Background unit tests for the handler: importing into a wallet that already + has a phrase leaves the first phrase's keystores, names, index set and + provenance intact, and adds the second phrase's index 0 plus funded indices + under a new source. Also: a held phrase is refused with nothing written; a + collision on a funded index refuses the whole import with nothing written; a + `storeAccount` failure partway leaves the source registered and a retry is + refused as already held; a lookup failure is skipped; index 0 is active + afterwards; and invalid word counts are refused. +- Popup tests for the view: Import is disabled when empty, an invalid phrase + stays on the phrase screen with the error, the password step's errors render, + and success navigates to the account screen. +- An e2e case in `e2e-tests/addWallet.test.ts`: import a second phrase from the + chooser and assert both groups appear, with the first group's rows unchanged. + Then re-import the same phrase and assert the refusal. + +## Left to the builder + +- How the discovery loop is parallelized, as long as order is kept and the time + is reasonable. +- Where the popup gets BIP-39 validation from (`stellar-hd-wallet` or the + `bip39` package already in the tree). +- Component structure and styles for the phrase screen, following the existing + add-wallet views. + +## Error copy for review + +No new error strings. Each state reuses copy the extension already ships: + +| State | Copy | Where it comes from | +| --------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------- | +| Wrong word count or bad phrase | "Invalid mnemonic phrase" | onboarding `RecoverAccount` | +| Wrong password, session unlocked | "Incorrect password" | `addRecoveryPhraseSource`, `addAccount` | +| Wrong password, session locked | "Unable to login" | `addRecoveryPhraseSource` | +| Phrase already in the wallet | "This recovery phrase is already in your wallet" | `PHRASE_ALREADY_HELD_ERROR` | +| A discovered account is already in the wallet | "This account is already in your wallet." | `ImportAccount` mapping of `ACCOUNT_ALREADY_EXISTS_ERROR` | +| A write failed partway | "Error adding recovery phrase" | `addRecoveryPhraseSource` | +| No software keystore | "This wallet has no password to check against" | `NO_SOFTWARE_KEYSTORE_ERROR` | + +None of these states were drawn in the mocks (#51 asked for them), so this table +is what to check against design. diff --git a/extension/e2e-tests/addWallet.test.ts b/extension/e2e-tests/addWallet.test.ts index 75a469b837..30f1932c95 100644 --- a/extension/e2e-tests/addWallet.test.ts +++ b/extension/e2e-tests/addWallet.test.ts @@ -1,3 +1,4 @@ +import { generateMnemonic } from "bip39"; import { Keypair } from "stellar-sdk"; import { test, expect } from "./test-fixtures"; @@ -5,6 +6,7 @@ import { loginToTestAccount } from "./helpers/login"; import { addAccountToPhrase, generateRecoveryPhraseSource, + importRecoveryPhraseSource, } from "./helpers/addWallet"; // loginToTestAccount sets this password on the wallet it imports. @@ -123,3 +125,80 @@ test("Generates a recovery phrase while an imported key is active", async ({ group(page, "Recovery phrase 2").getByTestId("wallet-row-select"), ).toHaveAttribute("aria-current", "true"); }); + +test("Imports a second recovery phrase without changing the first", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + await openSheet(page); + // With one phrase the sheet may not label the group, so record every row. + await expect(page.getByTestId("wallet-row-select").first()).toBeVisible(); + const firstBefore = await page + .getByTestId("wallet-row-select") + .allTextContents(); + expect(firstBefore.length).toBeGreaterThan(0); + const firstRows = group(page, "Recovery phrase 1").getByTestId( + "wallet-row-select", + ); + + await importRecoveryPhraseSource({ + page, + password: WALLET_PASSWORD, + words: generateMnemonic().split(" "), + }); + + // Discovery looks up ~49 addresses on mainnet before the view returns. + await openSheet(page); + await expect(page.getByTestId("account-sheet-group-toggle")).toHaveText([ + "Recovery phrase 1", + "Recovery phrase 2", + ]); + await expect(firstRows).toHaveText(firstBefore); + await expect( + group(page, "Recovery phrase 2").getByTestId("wallet-row-select"), + ).toHaveCount(1); +}); + +test("Refuses a recovery phrase the wallet already holds", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + const words = generateMnemonic().split(" "); + await openSheet(page); + await importRecoveryPhraseSource({ + page, + password: WALLET_PASSWORD, + words, + }); + + await openSheet(page); + await expect(page.getByTestId("account-sheet-group-toggle")).toHaveText([ + "Recovery phrase 1", + "Recovery phrase 2", + ]); + await importRecoveryPhraseSource({ + page, + password: WALLET_PASSWORD, + words, + }); + await expect( + page.getByText("This recovery phrase is already in your wallet"), + ).toBeVisible({ timeout: 30000 }); + + // Back out of the phrase screen and the chooser to the account view. + await page.goBack(); + await page.goBack(); + await openSheet(page); + await expect(page.getByTestId("account-sheet-group-toggle")).toHaveText([ + "Recovery phrase 1", + "Recovery phrase 2", + ]); +}); diff --git a/extension/e2e-tests/helpers/addWallet.ts b/extension/e2e-tests/helpers/addWallet.ts index dac29e06ea..881b9e5527 100644 --- a/extension/e2e-tests/helpers/addWallet.ts +++ b/extension/e2e-tests/helpers/addWallet.ts @@ -58,3 +58,25 @@ export const generateRecoveryPhraseSource = async ({ await page.getByRole("button", { name: "Create", exact: true }).click(); return words; }; + +/** + * From an open account sheet: Add wallet, Import Recovery Phrase, enter the + * words, and confirm with the password. Discovery runs after the password, so + * callers should allow a generous wait for the account view. + */ +export const importRecoveryPhraseSource = async ({ + page, + password, + words, +}: { + page: Page; + password: string; + words: string[]; +}) => { + await page.getByTestId("add-wallet").click(); + await page.getByTestId("add-wallet-import-phrase").click(); + await page.getByTestId("import-phrase-input").fill(words.join(" ")); + await page.getByTestId("import-phrase-submit").click(); + await page.locator("#password-input").fill(password); + await page.getByRole("button", { name: "Import", exact: true }).click(); +}; diff --git a/extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts b/extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts new file mode 100644 index 0000000000..95c243ec10 --- /dev/null +++ b/extension/src/background/messageListener/__tests__/discoverFundedIndices.test.ts @@ -0,0 +1,157 @@ +import { captureException } from "@sentry/browser"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { discoverFundedIndices } from "../helpers/discover-funded-indices"; + +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); + +const pk = (i: number) => `PK${i}`; +const wallet = { + getPublicKey: (i: number) => pk(i), + getSecret: (i: number) => `SK${i}`, +} as any; + +const mockFetch = (handler: (publicKey: string) => Promise) => { + const fn = jest.fn((url: string) => { + const publicKey = url.split("/accounts/")[1]; + return handler(publicKey).then((body) => ({ + json: () => Promise.resolve(body), + })); + }); + global.fetch = fn as unknown as typeof fetch; + return fn; +}; + +describe("discoverFundedIndices", () => { + beforeEach(() => { + (captureException as jest.Mock).mockClear(); + }); + + it("returns funded indices in ascending order", async () => { + const delays: Record = { + [pk(7)]: 5, + [pk(2)]: 30, + [pk(49)]: 1, + }; + mockFetch( + (publicKey) => + new Promise((resolve) => + setTimeout( + () => + resolve( + publicKey in delays + ? { account_id: publicKey } + : { status: 404 }, + ), + delays[publicKey] ?? 0, + ), + ), + ); + + expect(await discoverFundedIndices({ wallet, from: 1, to: 49 })).toEqual([ + 2, 7, 49, + ]); + }); + + it("treats a 404 body as not funded", async () => { + mockFetch(async () => ({ status: 404 })); + + expect(await discoverFundedIndices({ wallet, from: 1, to: 5 })).toEqual([]); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("skips an index whose lookup throws and reports it", async () => { + mockFetch(async (publicKey) => { + if (publicKey === pk(3)) throw new Error("network down"); + return publicKey === pk(4) ? { account_id: publicKey } : { status: 404 }; + }); + + expect(await discoverFundedIndices({ wallet, from: 1, to: 5 })).toEqual([ + 4, + ]); + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it("queries mainnet Horizon", async () => { + const fetchMock = mockFetch(async () => ({ status: 404 })); + + await discoverFundedIndices({ wallet, from: 1, to: 10 }); + + expect(fetchMock).toHaveBeenCalledTimes(10); + fetchMock.mock.calls.forEach(([url]) => { + expect( + url.startsWith(`${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/`), + ).toBe(true); + }); + }); + + it("keeps the requests in flight bounded", async () => { + let inFlight = 0; + let peak = 0; + mockFetch(async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 2)); + inFlight -= 1; + return { status: 404 }; + }); + + await discoverFundedIndices({ wallet, from: 1, to: 49 }); + + expect(peak).toBeLessThanOrEqual(10); + // More than one at a time, so the lookups really run in parallel. + expect(peak).toBeGreaterThan(1); + }); + + it("gives up on a lookup that stalls past the timeout and returns the rest", async () => { + // AbortSignal.timeout runs on the runtime's own timer, which fake timers + // don't drive. Hand out signals this test can abort instead. + const controllers: AbortController[] = []; + const timeoutSpy = jest + .spyOn(AbortSignal, "timeout") + .mockImplementation(() => { + const controller = new AbortController(); + controllers.push(controller); + return controller.signal; + }); + let stalled: () => void = () => {}; + const isStalled = new Promise((r) => { + stalled = r; + }); + global.fetch = jest.fn( + (url: string, { signal }: { signal: AbortSignal }) => { + const publicKey = url.split("/accounts/")[1]; + if (publicKey === pk(2)) { + // Never resolves; only the abort ends it. + return new Promise((_, reject) => { + signal.addEventListener("abort", () => + reject(new DOMException("timed out", "TimeoutError")), + ); + stalled(); + }); + } + return Promise.resolve({ + json: () => + Promise.resolve( + publicKey === pk(1) || publicKey === pk(3) + ? { account_id: publicKey } + : { status: 404 }, + ), + }); + }, + ) as unknown as typeof fetch; + + try { + const result = discoverFundedIndices({ wallet, from: 1, to: 3 }); + await isStalled; + expect(timeoutSpy).toHaveBeenCalledWith(5000); + // Fire the timeouts. Only index 2's lookup is still waiting on one. + controllers.forEach((controller) => controller.abort()); + + expect(await result).toEqual([1, 3]); + expect(captureException).toHaveBeenCalledTimes(1); + } finally { + timeoutSpy.mockRestore(); + } + }); +}); diff --git a/extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts b/extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts new file mode 100644 index 0000000000..07285fc5e9 --- /dev/null +++ b/extension/src/background/messageListener/__tests__/importRecoveryPhrase.test.ts @@ -0,0 +1,437 @@ +import { sessionSlice } from "background/ducks/session"; +import { popupMessageListener } from "background/messageListener/popupMessageListener"; +import { SERVICE_TYPES } from "@shared/constants/services"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import { ACCOUNT_ALREADY_EXISTS_ERROR } from "@shared/constants/errors"; +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { + ACCOUNT_SOURCE_VECTORS, + AUTH_KEYPAIR_VECTORS, +} from "@shared/api/helpers/authKeypairVectors"; +import { + Account, + AccountProvenanceMap, + SecretSource, + SecretSourceType, +} from "@shared/api/types"; +import { + mockDataStorage, + mockSessionStore, + mockKeyManager, + MockBrowserAlarm, + mockStorageApi, +} from "background/messageListener/helpers/test-helpers"; +import { storeAccount } from "background/messageListener/helpers/store-account"; +import { + addSecretSource, + getSecretSources, +} from "background/helpers/secret-sources"; +import { + ACCOUNT_NAME_LIST_ID, + ACCOUNT_PROVENANCE_ID, + APPLICATION_ID, + DERIVATION_INDICES_ID, + KEY_ID, + KEY_ID_LIST, + REBRAND_ANNOUNCEMENT_STATUS_ID, + SECRET_SOURCES_ID, +} from "constants/localStorageTypes"; +import { REBRAND_ANNOUNCEMENT_STATUS } from "constants/rebrandAnnouncement"; +import { PHRASE_ALREADY_HELD_ERROR } from "../handlers/addRecoveryPhraseSource"; +import { IMPORT_PHRASE_FAILED_ERROR } from "../handlers/importRecoveryPhrase"; + +const [PHRASE_1, PHRASE_2] = AUTH_KEYPAIR_VECTORS; +const PASSWORD = "test"; +const DEPTH = 50; + +jest.mock("background/helpers/link-sources", () => ({ + linkWalletSources: jest.fn().mockResolvedValue(undefined), +})); + +// Delegates to the real storeAccount; a test can make one call fail. +jest.mock("background/messageListener/helpers/store-account", () => { + const actual = jest.requireActual( + "background/messageListener/helpers/store-account", + ); + return { + ...actual, + storeAccount: jest.fn(actual.storeAccount), + }; +}); +const mockStoreAccount = storeAccount as jest.MockedFunction< + typeof storeAccount +>; +const realStoreAccount = jest.requireActual( + "background/messageListener/helpers/store-account", +).storeAccount; + +// StellarHDWallet.fromMnemonic cannot run under jest (bip39 CJS interop), so +// derive from the pinned (mnemonic, index) rows, and past them from a +// deterministic seed per (mnemonic, index). Only the pinned phrases resolve. +const mockGenerated: string[] = []; +jest.mock("stellar-hd-wallet", () => { + const { createHash } = jest.requireActual("crypto"); + const { Keypair } = jest.requireActual("stellar-sdk"); + const { ACCOUNT_SOURCE_VECTORS: vectors } = jest.requireActual( + "@shared/api/helpers/authKeypairVectors", + ); + return { + generateMnemonic: () => mockGenerated.shift(), + fromMnemonic: (mnemonic: string) => { + const rows = vectors.find((v: any) => v.mnemonic === mnemonic).accounts; + const keypair = (i: number) => + Keypair.fromRawEd25519Seed( + createHash("sha256").update(`${mnemonic}:${i}`).digest(), + ); + return { + getPublicKey: (i: number) => + rows[i] ? rows[i].publicKey : keypair(i).publicKey(), + getSecret: (i: number) => + rows[i] ? rows[i].secret : keypair(i).secret(), + }; + }, + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromMnemonic } = require("stellar-hd-wallet"); +const wallet2 = fromMnemonic(PHRASE_2.mnemonic); +const ROWS_1 = ACCOUNT_SOURCE_VECTORS[0].accounts; + +const testAlarm = new MockBrowserAlarm(() => {}); +const send = (request: object) => + popupMessageListener( + request as any, + mockSessionStore, + mockDataStorage, + mockKeyManager, + testAlarm as any, + { id: "fake-extension-id" }, + ) as Promise; + +const importPhrase = (mnemonicPhrase: string, password = PASSWORD) => + send({ + type: SERVICE_TYPES.IMPORT_RECOVERY_PHRASE, + password, + mnemonicPhrase, + }); + +/** Funded addresses for the mocked Horizon. */ +let funded = new Set(); +const fund = (...indices: number[]) => { + funded = new Set(indices.map((i) => wallet2.getPublicKey(i))); +}; + +const HORIZON_ACCOUNTS = `${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/`; + +/** The mainnet account lookups discovery made, ignoring other requests. */ +const horizonLookups = () => + (global.fetch as jest.Mock).mock.calls.filter( + ([url]) => typeof url === "string" && url.startsWith(HORIZON_ACCOUNTS), + ); + +const allAccounts = (): Account[] => + mockSessionStore.getState().session.allAccounts ?? []; + +/** One phrase source (Seed Phrase 1) with accounts at indices 0 and 1. */ +const createWallet = async () => { + mockGenerated.push(PHRASE_1.mnemonic); + await send({ + type: SERVICE_TYPES.CREATE_ACCOUNT, + password: PASSWORD, + isOverwritingAccount: false, + }); + await send({ type: SERVICE_TYPES.ADD_ACCOUNT, password: PASSWORD }); +}; + +/** The first source's stored state, to compare after an import. */ +const snapshotFirstSource = async () => { + const provenance = ((await mockDataStorage.getItem(ACCOUNT_PROVENANCE_ID)) || + {}) as AccountProvenanceMap; + const keyIds = Object.keys(provenance).filter( + (id) => provenance[id].sourceId === PHRASE_1.userId, + ); + const names = (await mockDataStorage.getItem(ACCOUNT_NAME_LIST_ID)) || {}; + const keystores: Record = {}; + for (const id of keyIds) { + keystores[id] = await mockDataStorage.getItem(`stellarkeys:${id}`); + } + return { + keyIds, + keystores, + names: Object.fromEntries(keyIds.map((id) => [id, names[id]])), + indices: ((await mockDataStorage.getItem(DERIVATION_INDICES_ID)) || {})[ + PHRASE_1.userId + ], + provenance: Object.fromEntries(keyIds.map((id) => [id, provenance[id]])), + }; +}; + +/** What "writes nothing" compares. */ +const snapshotWrites = async () => ({ + keyIdList: await mockDataStorage.getItem(KEY_ID_LIST), + secretSources: await mockDataStorage.getItem(SECRET_SOURCES_ID), + names: await mockDataStorage.getItem(ACCOUNT_NAME_LIST_ID), + keyId: await mockDataStorage.getItem(KEY_ID), +}); + +const newAccounts = () => + allAccounts().filter(({ sourceId }) => sourceId === PHRASE_2.userId); + +const indicesOf = async (sourceId: string) => + ((await mockDataStorage.getItem(DERIVATION_INDICES_ID)) || {})[sourceId]; + +describe("importing an additional recovery phrase", () => { + jest.setTimeout(120_000); + + let before: Awaited>; + + beforeEach(async () => { + await mockStorageApi.clear(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + mockGenerated.length = 0; + mockStoreAccount.mockReset(); + mockStoreAccount.mockImplementation(realStoreAccount); + funded = new Set(); + global.fetch = jest.fn((url: string) => { + const publicKey = String(url).startsWith(HORIZON_ACCOUNTS) + ? String(url).slice(HORIZON_ACCOUNTS.length) + : ""; + return Promise.resolve({ + json: () => + Promise.resolve( + funded.has(publicKey) ? { account_id: publicKey } : {}, + ), + }); + }) as unknown as typeof fetch; + + await createWallet(); + before = await snapshotFirstSource(); + (global.fetch as jest.Mock).mockClear(); + }); + + it("adds the second phrase as Seed Phrase 2 with index 0 and its funded indices", async () => { + fund(3, 10); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBeUndefined(); + expect(newAccounts().map(({ publicKey }) => publicKey)).toEqual([ + wallet2.getPublicKey(0), + wallet2.getPublicKey(3), + wallet2.getPublicKey(10), + ]); + expect(newAccounts().every(({ imported }) => !imported)).toBe(true); + expect(await indicesOf(PHRASE_2.userId)).toEqual([0, 3, 10]); + expect( + (res.secretSources as SecretSource[]).map(({ id, label }) => [id, label]), + ).toEqual([ + [PHRASE_1.userId, "Seed Phrase 1"], + [PHRASE_2.userId, "Seed Phrase 2"], + ]); + expect(res.allAccounts).toHaveLength(5); + // Indices 1 to 49 are checked. + expect(horizonLookups()).toHaveLength(DEPTH - 1); + }); + + it("leaves the first phrase's keystores, names, indices and provenance unchanged", async () => { + fund(3, 10); + const keyIdList = await mockDataStorage.getItem(KEY_ID_LIST); + + await importPhrase(PHRASE_2.mnemonic); + + expect(await snapshotFirstSource()).toEqual(before); + expect(before.keyIds).toHaveLength(2); + expect(before.indices).toEqual([0, 1]); + expect( + (await mockDataStorage.getItem(KEY_ID_LIST)).slice(0, keyIdList.length), + ).toEqual(keyIdList); + }); + + it("adds only index 0 when nothing is funded", async () => { + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBeUndefined(); + expect(newAccounts().map(({ publicKey }) => publicKey)).toEqual([ + wallet2.getPublicKey(0), + ]); + expect(await indicesOf(PHRASE_2.userId)).toEqual([0]); + }); + + it("makes the new phrase's index 0 active", async () => { + fund(3, 10); + + const res = await importPhrase(PHRASE_2.mnemonic); + + const first = newAccounts()[0]; + expect(res.publicKey).toBe(wallet2.getPublicKey(0)); + expect(res.activeAccountId).toBe(first.id); + expect(await mockDataStorage.getItem(KEY_ID)).toBe(first.id); + expect(mockSessionStore.getState().session.publicKey).toBe( + wallet2.getPublicKey(0), + ); + }); + + it("accepts a phrase with capitals, newlines and extra spaces and registers the clean phrase's source id", async () => { + const messy = `\n ${PHRASE_2.mnemonic + .toUpperCase() + .split(" ") + .join(" \n\t ")} `; + + const res = await importPhrase(messy); + + expect(res.error).toBeUndefined(); + expect( + Object.keys(await getSecretSources({ localStore: mockDataStorage })), + ).toEqual([PHRASE_1.userId, PHRASE_2.userId]); + expect(newAccounts().map(({ publicKey }) => publicKey)).toEqual([ + wallet2.getPublicKey(0), + ]); + }); + + it('refuses an invalid phrase with "Invalid mnemonic phrase"', async () => { + const writes = await snapshotWrites(); + const fifteen = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon address"; + const badChecksum = PHRASE_1.mnemonic.replace(/about$/, "abandon"); + + for (const phrase of [fifteen, badChecksum]) { + const res = await importPhrase(phrase); + expect(res.error).toBe("Invalid mnemonic phrase"); + } + + expect(await snapshotWrites()).toEqual(writes); + expect(horizonLookups()).toHaveLength(0); + }); + + it("refuses a phrase the wallet already holds with PHRASE_ALREADY_HELD_ERROR and makes no Horizon request", async () => { + const writes = await snapshotWrites(); + + const res = await importPhrase(PHRASE_1.mnemonic.toUpperCase()); + + expect(res.error).toBe(PHRASE_ALREADY_HELD_ERROR); + expect(horizonLookups()).toHaveLength(0); + expect(await snapshotWrites()).toEqual(writes); + }); + + it("refuses the whole import when a funded index is already in the wallet", async () => { + await send({ + type: SERVICE_TYPES.IMPORT_ACCOUNT, + password: PASSWORD, + privateKey: wallet2.getSecret(3), + }); + fund(3, 10); + const writes = await snapshotWrites(); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(ACCOUNT_ALREADY_EXISTS_ERROR); + expect( + (await getSecretSources({ localStore: mockDataStorage }))[ + PHRASE_2.userId + ], + ).toBeUndefined(); + expect(newAccounts()).toHaveLength(0); + expect(await snapshotWrites()).toEqual(writes); + }); + + it("refuses when index 0 was earlier imported as a secret key", async () => { + await send({ + type: SERVICE_TYPES.IMPORT_ACCOUNT, + password: PASSWORD, + privateKey: wallet2.getSecret(0), + }); + const writes = await snapshotWrites(); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(ACCOUNT_ALREADY_EXISTS_ERROR); + expect(await snapshotWrites()).toEqual(writes); + }); + + it('refuses a wrong password on a locked session with "Unable to login" and writes nothing', async () => { + const writes = await snapshotWrites(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + + const res = await importPhrase(PHRASE_2.mnemonic, "wrong"); + + expect(res.error).toBe("Unable to login"); + expect(await snapshotWrites()).toEqual(writes); + expect(horizonLookups()).toHaveLength(0); + }); + + it("leaves the source registered when a storeAccount fails partway, and a retry is refused as already held", async () => { + fund(3); + mockStoreAccount + .mockImplementationOnce(realStoreAccount) + .mockImplementationOnce(() => Promise.reject(new Error("disk full"))); + const activeBefore = await mockDataStorage.getItem(KEY_ID); + const publicKeyBefore = mockSessionStore.getState().session.publicKey; + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(IMPORT_PHRASE_FAILED_ERROR); + // The active account is put back to the one active before the import. + expect(await mockDataStorage.getItem(KEY_ID)).toBe(activeBefore); + expect(mockSessionStore.getState().session.publicKey).toBe(publicKeyBefore); + expect( + (await getSecretSources({ localStore: mockDataStorage }))[ + PHRASE_2.userId + ], + ).toBeDefined(); + + const retry = await importPhrase(PHRASE_2.mnemonic); + expect(retry.error).toBe(PHRASE_ALREADY_HELD_ERROR); + }); + + it("refuses with PHRASE_ALREADY_HELD_ERROR when the phrase is registered while discovery runs", async () => { + fund(3, 10); + const writes = await snapshotWrites(); + mockStoreAccount.mockClear(); + const realFetch = global.fetch as jest.Mock; + let registered: Promise | undefined; + global.fetch = jest.fn((url: string) => { + // Another window finishes importing the same phrase mid-discovery. + registered ??= addSecretSource({ + localStore: mockDataStorage, + id: PHRASE_2.userId, + type: SecretSourceType.PHRASE, + }).then(() => undefined); + return registered.then(() => realFetch(url)); + }) as unknown as typeof fetch; + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBe(PHRASE_ALREADY_HELD_ERROR); + expect(newAccounts()).toHaveLength(0); + expect(mockStoreAccount).not.toHaveBeenCalled(); + const after = await snapshotWrites(); + expect(after.keyIdList).toEqual(writes.keyIdList); + expect(after.names).toEqual(writes.names); + expect(after.keyId).toEqual(writes.keyId); + }); + + it("does not touch application state or the rebrand announcement status", async () => { + await mockDataStorage.setItem( + APPLICATION_ID, + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + await mockDataStorage.setItem( + REBRAND_ANNOUNCEMENT_STATUS_ID, + REBRAND_ANNOUNCEMENT_STATUS.PENDING, + ); + + const res = await importPhrase(PHRASE_2.mnemonic); + + expect(res.error).toBeUndefined(); + expect(await mockDataStorage.getItem(APPLICATION_ID)).toBe( + APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + ); + expect(await mockDataStorage.getItem(REBRAND_ANNOUNCEMENT_STATUS_ID)).toBe( + REBRAND_ANNOUNCEMENT_STATUS.PENDING, + ); + expect(allAccounts().map(({ publicKey }) => publicKey)).toEqual( + expect.arrayContaining([ROWS_1[0].publicKey, ROWS_1[1].publicKey]), + ); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts b/extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts new file mode 100644 index 0000000000..5c1388dbbe --- /dev/null +++ b/extension/src/background/messageListener/__tests__/verifyAddPassword.test.ts @@ -0,0 +1,100 @@ +import { Keypair } from "stellar-sdk"; + +import { sessionSlice } from "background/ducks/session"; +import { + mockDataStorage, + mockSessionStore, + mockKeyManager, + mockStorageApi, + MockBrowserAlarm, +} from "background/messageListener/helpers/test-helpers"; +import { storeAccount } from "background/messageListener/helpers/store-account"; +import { storeHardwareWalletAccount } from "background/messageListener/helpers/store-hardware-wallet"; +import { WalletType } from "@shared/constants/hardwareWallet"; +import { + NO_SOFTWARE_KEYSTORE_ERROR, + verifyAddPassword, +} from "../helpers/verify-add-password"; + +const PASSWORD = "test"; +const testAlarm = new MockBrowserAlarm(() => {}); + +const verify = (password: string) => + verifyAddPassword({ + password, + localStore: mockDataStorage, + sessionStore: mockSessionStore, + keyManager: mockKeyManager, + sessionTimer: testAlarm as any, + }); + +const createSoftwareWallet = async () => { + const secret = Keypair.random().secret(); + await storeAccount({ + password: PASSWORD, + keyPair: { + publicKey: Keypair.fromSecret(secret).publicKey(), + privateKey: secret, + }, + sourceId: "source-1", + imported: true, + isSettingHashKey: true, + sessionStore: mockSessionStore, + localStore: mockDataStorage, + keyManager: mockKeyManager, + }); +}; + +describe("verifyAddPassword", () => { + jest.setTimeout(60_000); + + beforeEach(async () => { + await mockStorageApi.clear(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + }); + + it("returns null for the right password", async () => { + await createSoftwareWallet(); + + expect(await verify(PASSWORD)).toBeNull(); + }); + + it('returns "Incorrect password" for a wrong password on an unlocked session', async () => { + await createSoftwareWallet(); + + expect(await verify("wrong")).toBe("Incorrect password"); + }); + + it('returns "Unable to login" for a wrong password on a locked session', async () => { + await createSoftwareWallet(); + mockSessionStore.dispatch(sessionSlice.actions.reset()); + + expect(await verify("wrong")).toBe("Unable to login"); + }); + + it("returns null for the right password on a locked session", async () => { + await createSoftwareWallet(); + // No active hash key: the session is locked. + mockSessionStore.dispatch(sessionSlice.actions.reset()); + + expect(await verify(PASSWORD)).toBeNull(); + }); + + it("returns NO_SOFTWARE_KEYSTORE_ERROR when there is no KEY_ID", async () => { + expect(await verify(PASSWORD)).toBe(NO_SOFTWARE_KEYSTORE_ERROR); + }); + + it("checks the software keystore while a hardware account is active", async () => { + await createSoftwareWallet(); + await storeHardwareWalletAccount({ + publicKey: Keypair.random().publicKey(), + hardwareWalletType: WalletType.LEDGER, + bipPath: "44'/148'/0'", + sessionStore: mockSessionStore, + localStore: mockDataStorage, + }); + + expect(await verify(PASSWORD)).toBeNull(); + expect(await verify("wrong")).toBe("Incorrect password"); + }); +}); diff --git a/extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts b/extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts index 07aab7bcec..676792c1bf 100644 --- a/extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts +++ b/extension/src/background/messageListener/handlers/addRecoveryPhraseSource.ts @@ -8,21 +8,14 @@ import { SecretSourceType } from "@shared/api/types"; import { ACCOUNT_ALREADY_EXISTS_ERROR } from "@shared/constants/errors"; import { DataStorageAccess } from "background/helpers/dataStorageAccess"; import { linkWalletSources } from "background/helpers/link-sources"; -import { - getActiveHashKeyCryptoKey, - SessionTimer, -} from "background/helpers/session"; +import { SessionTimer } from "background/helpers/session"; import { addSecretSource, getPhraseSourceId, getSecretSources, listSecretSources, } from "background/helpers/secret-sources"; -import { - getIsHardwareWalletActive, - getActiveAccountId, -} from "background/helpers/account"; -import { KEY_ID } from "constants/localStorageTypes"; +import { getActiveAccountId } from "background/helpers/account"; import { allAccountsSelector, buildHasPrivateKeySelector, @@ -31,9 +24,10 @@ import { publicKeySelector, setPendingMnemonicPhrase, } from "background/ducks/session"; -import { loginToAllAccounts } from "../helpers/login-all-accounts"; -import { getNonHwKeyID } from "../helpers/get-non-hw-key-id"; -import { unlockKeystore } from "../helpers/unlock-keystore"; +import { + NO_SOFTWARE_KEYSTORE_ERROR, + verifyAddPassword, +} from "../helpers/verify-add-password"; import { storeAccount } from "../helpers/store-account"; const { fromMnemonic } = StellarHDWallet; @@ -42,8 +36,7 @@ export const PENDING_PHRASE_MISSING_ERROR = "The new recovery phrase is no longer available. Start again."; export const PHRASE_ALREADY_HELD_ERROR = "This recovery phrase is already in your wallet"; -export const NO_SOFTWARE_KEYSTORE_ERROR = - "This wallet has no password to check against"; +export { NO_SOFTWARE_KEYSTORE_ERROR }; /* Stores the phrase `generateRecoveryPhrase` is holding as a new phrase source @@ -81,41 +74,15 @@ export const addRecoveryPhraseSource = async ({ return { error: PENDING_PHRASE_MISSING_ERROR }; } - const keyID = (await getIsHardwareWalletActive({ localStore })) - ? await getNonHwKeyID({ localStore }) - : (await localStore.getItem(KEY_ID)) || ""; - - // Onboarding always writes a password-protected software keystore before a - // hardware account can be added, so this is unreachable today. Without - // one, every password would fail as if it were wrong. - if (!keyID) { - return { error: NO_SOFTWARE_KEYSTORE_ERROR }; - } - - if ((await getActiveHashKeyCryptoKey({ sessionStore })) === null) { - try { - await loginToAllAccounts( - password, - localStore, - sessionStore, - keyManager, - sessionTimer, - ); - } catch (e) { - captureException( - `Error logging in to all accounts in Add Recovery Phrase Source - ${JSON.stringify( - e, - )}`, - ); - return { error: "Unable to login" }; - } - } - - try { - await unlockKeystore({ keyID, password, keyManager }); - } catch (e) { - console.error(e); - return { error: "Incorrect password" }; + const passwordError = await verifyAddPassword({ + password, + localStore, + sessionStore, + keyManager, + sessionTimer, + }); + if (passwordError) { + return { error: passwordError }; } let sourceId: string; diff --git a/extension/src/background/messageListener/handlers/importRecoveryPhrase.ts b/extension/src/background/messageListener/handlers/importRecoveryPhrase.ts new file mode 100644 index 0000000000..8628602bd7 --- /dev/null +++ b/extension/src/background/messageListener/handlers/importRecoveryPhrase.ts @@ -0,0 +1,199 @@ +import { Store } from "redux"; +import StellarHDWallet from "stellar-hd-wallet"; +import { KeyManager } from "@stellar/typescript-wallet-sdk-km"; +import { captureException } from "@sentry/browser"; + +import { ImportRecoveryPhraseMessage } from "@shared/api/types/message-request"; +import { SecretSourceType } from "@shared/api/types"; +import { ACCOUNT_ALREADY_EXISTS_ERROR } from "@shared/constants/errors"; +import { + isValidImportMnemonic, + normalizeMnemonic, +} from "@shared/api/helpers/mnemonic"; +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { linkWalletSources } from "background/helpers/link-sources"; +import { SessionTimer } from "background/helpers/session"; +import { + addSecretSource, + getPhraseSourceId, + getSecretSources, + listSecretSources, +} from "background/helpers/secret-sources"; +import { getActiveAccountId } from "background/helpers/account"; +import { + allAccountsSelector, + buildHasPrivateKeySelector, + publicKeySelector, +} from "background/ducks/session"; +import { verifyAddPassword } from "../helpers/verify-add-password"; +import { discoverFundedIndices } from "../helpers/discover-funded-indices"; +import { storeAccount } from "../helpers/store-account"; +import { activatePublicKey } from "../helpers/activate-public-key"; +import { PHRASE_ALREADY_HELD_ERROR } from "./addRecoveryPhraseSource"; + +const { fromMnemonic } = StellarHDWallet; + +export const IMPORT_PHRASE_FAILED_ERROR = "Error adding recovery phrase"; + +/* + Adds a recovery phrase the user typed to a live wallet as a new phrase + source: index 0 always, then every index from 1 to `discoveryDepth - 1` + that is funded on mainnet. Every refusal returns before the first write. + + Unlike onboarding recover, this never clears the wallet: existing sources, + accounts, names, indices, provenance and the application state stay as + they are. +*/ +export const importRecoveryPhrase = async ({ + request, + localStore, + sessionStore, + keyManager, + sessionTimer, + discoveryDepth, +}: { + request: ImportRecoveryPhraseMessage; + localStore: DataStorageAccess; + sessionStore: Store; + keyManager: KeyManager; + sessionTimer: SessionTimer; + discoveryDepth: number; +}) => { + const { password } = request; + const mnemonicPhrase = normalizeMnemonic(request.mnemonicPhrase || ""); + + if (!isValidImportMnemonic(mnemonicPhrase)) { + return { error: "Invalid mnemonic phrase" }; + } + + const passwordError = await verifyAddPassword({ + password, + localStore, + sessionStore, + keyManager, + sessionTimer, + }); + if (passwordError) { + return { error: passwordError }; + } + + let sourceId: string; + let wallet: ReturnType; + try { + sourceId = await getPhraseSourceId(mnemonicPhrase); + wallet = fromMnemonic(mnemonicPhrase); + } catch (e) { + captureException( + `Error deriving imported phrase source: ${JSON.stringify(e)}`, + ); + return { error: IMPORT_PHRASE_FAILED_ERROR }; + } + + const isSourceHeld = async () => + Boolean((await getSecretSources({ localStore }))[sourceId]); + + // Checked before discovery to skip the lookups for a held phrase. + if (await isSourceHeld()) { + return { error: PHRASE_ALREADY_HELD_ERROR }; + } + + const fundedIndices = await discoverFundedIndices({ + wallet, + from: 1, + to: discoveryDepth - 1, + }); + const indices = [0, ...fundedIndices]; + + // Checked again after discovery: another import of the same phrase may + // have finished while the lookups ran. + if (await isSourceHeld()) { + return { error: PHRASE_ALREADY_HELD_ERROR }; + } + + // Collisions are refused, never merged: if any address this phrase would + // add is already in the wallet (say, imported earlier as a secret key), + // nothing is added. + const held = new Set( + allAccountsSelector(sessionStore.getState()).map( + ({ publicKey }) => publicKey, + ), + ); + if (indices.some((i) => held.has(wallet.getPublicKey(i)))) { + return { error: ACCOUNT_ALREADY_EXISTS_ERROR }; + } + + const previousActiveId = await getActiveAccountId({ localStore }); + + try { + // The source goes first, so a failure partway leaves a held source that + // a retry is refused against, never accounts with no source. + await addSecretSource({ + localStore, + id: sourceId, + type: SecretSourceType.PHRASE, + }); + + for (const i of indices) { + await storeAccount({ + password, + keyPair: { + publicKey: wallet.getPublicKey(i), + privateKey: wallet.getSecret(i), + }, + mnemonicPhrase, + phraseSourceId: sourceId, + sourceId, + derivationIndex: i, + sessionStore, + keyManager, + localStore, + }); + } + + // storeAccount leaves the last stored account active; the phrase's + // first account is the one to land on. + const first = allAccountsSelector(sessionStore.getState()).find( + (account) => + account.sourceId === sourceId && + account.publicKey === wallet.getPublicKey(0), + ); + await activatePublicKey({ + id: first?.id, + publicKey: wallet.getPublicKey(0), + sessionStore, + localStore, + }); + } catch (e) { + captureException(`Error importing recovery phrase: ${JSON.stringify(e)}`); + // storeAccount moves the active account to each account it stores. Put + // back the one the popup still shows. + try { + await activatePublicKey({ + id: previousActiveId, + sessionStore, + localStore, + }); + } catch (restoreError) { + captureException( + `Error restoring active account after failed import: ${JSON.stringify( + restoreError, + )}`, + ); + } + return { error: IMPORT_PHRASE_FAILED_ERROR }; + } + + // The new phrase is a source of its own. Not awaited. + void linkWalletSources({ sessionStore, localStore }); + + const currentState = sessionStore.getState(); + const hasPrivateKeySelector = buildHasPrivateKeySelector(localStore); + + return { + publicKey: publicKeySelector(currentState), + activeAccountId: await getActiveAccountId({ localStore }), + allAccounts: allAccountsSelector(currentState), + hasPrivateKey: await hasPrivateKeySelector(currentState), + secretSources: await listSecretSources({ localStore }), + }; +}; diff --git a/extension/src/background/messageListener/handlers/recoverAccount.ts b/extension/src/background/messageListener/handlers/recoverAccount.ts index 4dfafcd5bd..bdc9884d39 100644 --- a/extension/src/background/messageListener/handlers/recoverAccount.ts +++ b/extension/src/background/messageListener/handlers/recoverAccount.ts @@ -23,7 +23,7 @@ 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 { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { discoverFundedIndices } from "../helpers/discover-funded-indices"; import { activatePublicKey } from "../helpers/activate-public-key"; import { addSecretSource, @@ -40,8 +40,6 @@ import { linkWalletSources } from "background/helpers/link-sources"; const { fromMnemonic } = StellarHDWallet; -const FUNDED_LOOKUP_TIMEOUT_MS = 5_000; - export const recoverAccount = async ({ request, localStore, @@ -150,43 +148,35 @@ export const recoverAccount = async ({ // lets check first couple of accounts and pre-load them if funded on mainnet - for (let i = 1; i <= numOfPublicKeysToCheck; i += 1) { - try { - const publicKey = wallet.getPublicKey(i); - const privateKey = wallet.getSecret(i); - - // Bounded: this runs under the account-mutation lock, and a stalled - // lookup would hold off sign-out and auto-lock. A timeout lands in - // the catch below and the index is skipped as unfunded. - const resp = await fetch( - `${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/${publicKey}`, - { signal: AbortSignal.timeout(FUNDED_LOOKUP_TIMEOUT_MS) }, - ); + const fundedIndices = await discoverFundedIndices({ + wallet, + from: 1, + to: numOfPublicKeysToCheck, + }); - const j = await resp.json(); - if (j.account_id) { - const newKeyPair = { - publicKey, - privateKey, - }; - - // Not `imported`. These are derived from the recovery phrase we - // just took, at derivation indices 1..n — the same thing - // `addAccount` produces, which stores them unflagged. "Imported" - // is reserved for accounts brought in from a secret key, which - // the phrase cannot re-derive. - await storeAccount({ - password, - keyPair: newKeyPair, - mnemonicPhrase: recoverMnemonic, - phraseSourceId, - sourceId: phraseSourceId, - derivationIndex: i, - sessionStore, - localStore, - keyManager, - }); - } + for (const i of fundedIndices) { + try { + const newKeyPair = { + publicKey: wallet.getPublicKey(i), + privateKey: wallet.getSecret(i), + }; + + // Not `imported`. These are derived from the recovery phrase we + // just took, at derivation indices 1..n — the same thing + // `addAccount` produces, which stores them unflagged. "Imported" + // is reserved for accounts brought in from a secret key, which + // the phrase cannot re-derive. + await storeAccount({ + password, + keyPair: newKeyPair, + mnemonicPhrase: recoverMnemonic, + phraseSourceId, + sourceId: phraseSourceId, + derivationIndex: i, + sessionStore, + localStore, + keyManager, + }); } catch (e) { captureException( `Error preloading account: ${JSON.stringify(e)} - ${i}`, diff --git a/extension/src/background/messageListener/helpers/discover-funded-indices.ts b/extension/src/background/messageListener/helpers/discover-funded-indices.ts new file mode 100644 index 0000000000..60a70400cd --- /dev/null +++ b/extension/src/background/messageListener/helpers/discover-funded-indices.ts @@ -0,0 +1,60 @@ +import StellarHDWallet from "stellar-hd-wallet"; +import { captureException } from "@sentry/browser"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; + +const MAX_IN_FLIGHT = 10; +// Callers run under the account-mutation lock, so a stalled lookup would +// hold off sign-out and auto-lock until it times out. +const LOOKUP_TIMEOUT_MS = 5_000; + +/* + Checks derivation indices `from` to `to` (inclusive) on mainnet and returns + the funded ones in ascending order. A lookup that throws, or takes longer + than LOOKUP_TIMEOUT_MS, counts as not found and is reported. +*/ +export const discoverFundedIndices = async ({ + wallet, + from, + to, +}: { + wallet: ReturnType; + from: number; + to: number; +}): Promise => { + const indices: number[] = []; + for (let i = from; i <= to; i += 1) { + indices.push(i); + } + + const funded = new Set(); + let next = 0; + + const worker = async () => { + while (next < indices.length) { + const i = indices[next]; + next += 1; + try { + const publicKey = wallet.getPublicKey(i); + const resp = await fetch( + `${MAINNET_NETWORK_DETAILS.networkUrl}/accounts/${publicKey}`, + { signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS) }, + ); + const j = await resp.json(); + if (j.account_id) { + funded.add(i); + } + } catch (e) { + captureException( + `Error preloading account: ${JSON.stringify(e)} - ${i}`, + ); + } + } + }; + + await Promise.all( + Array.from({ length: Math.min(MAX_IN_FLIGHT, indices.length) }, worker), + ); + + return indices.filter((i) => funded.has(i)); +}; diff --git a/extension/src/background/messageListener/helpers/verify-add-password.ts b/extension/src/background/messageListener/helpers/verify-add-password.ts new file mode 100644 index 0000000000..85196e8245 --- /dev/null +++ b/extension/src/background/messageListener/helpers/verify-add-password.ts @@ -0,0 +1,75 @@ +import { Store } from "redux"; +import { KeyManager } from "@stellar/typescript-wallet-sdk-km"; +import { captureException } from "@sentry/browser"; + +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { + getActiveHashKeyCryptoKey, + SessionTimer, +} from "background/helpers/session"; +import { getIsHardwareWalletActive } from "background/helpers/account"; +import { KEY_ID } from "constants/localStorageTypes"; +import { loginToAllAccounts } from "./login-all-accounts"; +import { getNonHwKeyID } from "./get-non-hw-key-id"; +import { unlockKeystore } from "./unlock-keystore"; + +export const NO_SOFTWARE_KEYSTORE_ERROR = + "This wallet has no password to check against"; + +/* + Checks a password against the active software keystore, for handlers that + add to a live wallet. Returns null when the password unlocks it, otherwise + the error string to hand back to the popup. +*/ +export const verifyAddPassword = async ({ + password, + localStore, + sessionStore, + keyManager, + sessionTimer, +}: { + password: string; + localStore: DataStorageAccess; + sessionStore: Store; + keyManager: KeyManager; + sessionTimer: SessionTimer; +}): Promise => { + const keyID = (await getIsHardwareWalletActive({ localStore })) + ? await getNonHwKeyID({ localStore }) + : (await localStore.getItem(KEY_ID)) || ""; + + // Onboarding always writes a password-protected software keystore before a + // hardware account can be added, so this is unreachable today. Without + // one, every password would fail as if it were wrong. + if (!keyID) { + return NO_SOFTWARE_KEYSTORE_ERROR; + } + + if ((await getActiveHashKeyCryptoKey({ sessionStore })) === null) { + try { + await loginToAllAccounts( + password, + localStore, + sessionStore, + keyManager, + sessionTimer, + ); + } catch (e) { + captureException( + `Error logging in to all accounts while verifying password - ${JSON.stringify( + e, + )}`, + ); + return "Unable to login"; + } + } + + try { + await unlockKeystore({ keyID, password, keyManager }); + } catch (e) { + console.error(e); + return "Incorrect password"; + } + + return null; +}; diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 82d13a86ab..2cfff99129 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -88,6 +88,7 @@ import { getMigratableAccounts } from "./handlers/getMigratableAccounts"; import { getMigratedMnemonicPhrase } from "./handlers/getMigratedMnemonicPhrase"; import { generateRecoveryPhrase } from "./handlers/generateRecoveryPhrase"; import { addRecoveryPhraseSource } from "./handlers/addRecoveryPhraseSource"; +import { importRecoveryPhrase } from "./handlers/importRecoveryPhrase"; import { migrateAccounts } from "./handlers/migrateAccounts"; import { addAssetsList } from "./handlers/addAssetsList"; import { modifyAssetsList } from "./handlers/modifyAssetsList"; @@ -114,6 +115,7 @@ import { getCachedSwapTopTokens } from "./handlers/getCachedSwapTopTokens"; import { cacheSwapTopTokens } from "./handlers/cacheSwapTopTokens"; const numOfPublicKeysToCheck = 5; +const phraseImportDiscoveryDepth = 50; let sidebarWindowId: number | null = null; @@ -254,6 +256,16 @@ export const popupMessageListener = ( sessionTimer, }); } + case SERVICE_TYPES.IMPORT_RECOVERY_PHRASE: { + return importRecoveryPhrase({ + request, + localStore, + sessionStore, + keyManager, + sessionTimer, + discoveryDepth: phraseImportDiscoveryDepth, + }); + } case SERVICE_TYPES.IMPORT_ACCOUNT: { return importAccount({ request, diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index 7c2d4cc8c2..ea4692e62e 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -28,6 +28,7 @@ import { AccountHistory } from "popup/views/AccountHistory"; import { AccountCreator } from "popup/views/AccountCreator"; import { AddWallet } from "popup/views/AddWallet"; import { CreateWallet } from "popup/views/CreateWallet"; +import { ImportRecoveryPhrase } from "popup/views/ImportRecoveryPhrase"; import { ManageAssetsLists } from "popup/views/ManageAssetsLists"; import { ImportAccount } from "popup/views/AddAccount/ImportAccount"; import { SelectHardwareWallet } from "popup/views/AddAccount/connect/SelectHardwareWallet"; @@ -212,6 +213,10 @@ export const Router = () => ( > }> }> + } + > }> }> void; confirmButtonTitle?: string; cancelButtonTitle?: string; + /** Rewrites the auth error before it is shown, e.g. to localized copy. */ + formatAuthError?: (error: string) => string; } export const EnterPassword = ({ @@ -32,6 +34,7 @@ export const EnterPassword = ({ onCancel, confirmButtonTitle, cancelButtonTitle, + formatAuthError, }: EnterPasswordProps) => { const { t } = useTranslation(); const titleLabel = title || t("Enter your password"); @@ -45,7 +48,11 @@ export const EnterPassword = ({ password: "", }; - const authError = useSelector(authErrorSelector); + const rawAuthError = useSelector(authErrorSelector); + const authError = + rawAuthError && formatAuthError + ? formatAuthError(rawAuthError) + : rawAuthError; const handleSubmit = async (values: FormValues) => { await onConfirm(values.password); diff --git a/extension/src/popup/constants/routes.ts b/extension/src/popup/constants/routes.ts index 9ba3006ed5..311e55854c 100644 --- a/extension/src/popup/constants/routes.ts +++ b/extension/src/popup/constants/routes.ts @@ -14,6 +14,7 @@ export enum ROUTES { swap = "/swap", addWallet = "/add-wallet", createWallet = "/add-wallet/create", + importRecoveryPhrase = "/add-wallet/import-phrase", addToken = "/add-token", signTransaction = "/sign-transaction", reviewAuthorization = "/review-auth", diff --git a/extension/src/popup/ducks/accountServices.ts b/extension/src/popup/ducks/accountServices.ts index e655c74632..575fdac397 100644 --- a/extension/src/popup/ducks/accountServices.ts +++ b/extension/src/popup/ducks/accountServices.ts @@ -10,6 +10,7 @@ import { APPLICATION_STATE } from "@shared/constants/applicationState"; import { addAccount as addAccountService, addRecoveryPhraseSource as addRecoveryPhraseSourceService, + importRecoveryPhrase as importRecoveryPhraseService, importAccount as importAccountService, importHardwareWallet as importHardwareWalletService, makeAccountActive as makeAccountActiveService, @@ -160,6 +161,31 @@ export const addRecoveryPhraseSource = createAsyncThunk< }, ); +export const importRecoveryPhrase = createAsyncThunk< + AddedAccountPayload, + { password: string; mnemonicPhrase: string }, + { rejectValue: ErrorMessage } +>( + "auth/importRecoveryPhrase", + async ({ password, mnemonicPhrase }, { getState, rejectWithValue }) => { + const activePublicKey = publicKeySelector(getState() as AppState); + + try { + return await importRecoveryPhraseService({ + activePublicKey, + password, + mnemonicPhrase, + }); + } catch (e) { + const message = e instanceof Error ? e.message : JSON.stringify(e); + console.error("Failed when importing a recovery phrase: ", message); + return rejectWithValue({ + errorMessage: message, + }); + } + }, +); + export const importAccount = createAsyncThunk< { publicKey: string; @@ -815,6 +841,8 @@ const authSlice = createSlice({ builder.addCase(addAccount.rejected, onAccountAddFailed); builder.addCase(addRecoveryPhraseSource.fulfilled, onAccountAdded); builder.addCase(addRecoveryPhraseSource.rejected, onAccountAddFailed); + builder.addCase(importRecoveryPhrase.fulfilled, onAccountAdded); + builder.addCase(importRecoveryPhrase.rejected, onAccountAddFailed); builder.addCase(importAccount.fulfilled, (state, action) => { const { publicKey, allAccounts, hasPrivateKey } = action.payload || { publicKey: "", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index dec50ada1d..f742ddde0e 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -353,10 +353,12 @@ "Import": "Import", "Import a Stellar secret key": "Import a Stellar secret key", "Import a wallet using its secret key.": "Import a wallet using its secret key.", + "Import Recovery Phrase": "Import Recovery Phrase", "Import Secret Key": "Import Secret Key", "Import Stellar Secret Key": "Import Stellar Secret Key", "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.", "Important": "Important", "Important, Please Read": "Important, Please Read", "Imported": "Imported", @@ -566,6 +568,7 @@ "Reserved Balance*": "Reserved Balance*", "Reset wallet": "Reset wallet", "Resource Fee": "Resource Fee", + "Restore your wallet with the 12 or 24-word recovery phrase": "Restore your wallet with the 12 or 24-word recovery phrase", "Retry": "Retry", "Reveal recovery phrase": "Reveal recovery phrase", "Reveal secret key": "Reveal secret key", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 77eeff487c..c83c68b149 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -353,10 +353,12 @@ "Import": "Importar", "Import a Stellar secret key": "Importar uma chave secreta Stellar", "Import a wallet using its secret key.": "Import a wallet using its secret key.", + "Import Recovery Phrase": "Import Recovery Phrase", "Import Secret Key": "Import Secret Key", "Import Stellar Secret Key": "Importar Stellar Secret Key", "Import wallet": "Importar carteira", "Import wallet from recovery phrase": "Importar carteira da frase de recuperação", + "Import wallets using a recovery phrase.": "Import wallets using a recovery phrase.", "Important": "Importante", "Important, Please Read": "Importante, Por Favor Leia", "Imported": "Importado", @@ -566,6 +568,7 @@ "Reserved Balance*": "Saldo Reservado*", "Reset wallet": "Redefinir carteira", "Resource Fee": "Taxa de Recurso", + "Restore your wallet with the 12 or 24-word recovery phrase": "Restore your wallet with the 12 or 24-word recovery phrase", "Retry": "Tentar novamente", "Reveal recovery phrase": "Revelar frase de recuperação", "Reveal secret key": "Revelar chave secreta", diff --git a/extension/src/popup/metrics/views.ts b/extension/src/popup/metrics/views.ts index 041c6a6af4..b0b38e274b 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -45,6 +45,10 @@ const SCREEN_BY_ROUTE: Partial> = { [ROUTES.accountHistory]: { screen_name: "account_history", flow: "history" }, [ROUTES.addWallet]: { screen_name: "add_wallet", flow: "onboarding" }, [ROUTES.createWallet]: { screen_name: "create_wallet", flow: "onboarding" }, + [ROUTES.importRecoveryPhrase]: { + screen_name: "import_recovery_phrase", + flow: "onboarding", + }, [ROUTES.importAccount]: { screen_name: "import_account", flow: "onboarding" }, [ROUTES.connectWallet]: { screen_name: "connect_wallet", flow: "onboarding" }, [ROUTES.connectWalletPlugin]: { diff --git a/extension/src/popup/views/AddWallet/index.tsx b/extension/src/popup/views/AddWallet/index.tsx index 032c9ca40c..f47599c163 100644 --- a/extension/src/popup/views/AddWallet/index.tsx +++ b/extension/src/popup/views/AddWallet/index.tsx @@ -12,9 +12,9 @@ import { View } from "popup/basics/layout/View"; import "./styles.scss"; /** - * The ways to add a wallet, opened from the account switcher. Importing a - * further recovery phrase joins this list once it can be done without - * replacing the wallet. + * The ways to add a wallet, opened from the account switcher: create, import + * a further recovery phrase, import a secret key, or connect a hardware + * wallet. None of them replaces the wallet. */ export const AddWallet = () => { const { t } = useTranslation(); @@ -27,6 +27,13 @@ export const AddWallet = () => { link: ROUTES.createWallet, testId: "add-wallet-create", }, + { + icon: , + title: t("Import Recovery Phrase"), + description: t("Import wallets using a recovery phrase."), + link: ROUTES.importRecoveryPhrase, + testId: "add-wallet-import-phrase", + }, { icon: , title: t("Import Secret Key"), diff --git a/extension/src/popup/views/ImportRecoveryPhrase/index.tsx b/extension/src/popup/views/ImportRecoveryPhrase/index.tsx new file mode 100644 index 0000000000..7ac2ce6575 --- /dev/null +++ b/extension/src/popup/views/ImportRecoveryPhrase/index.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { Button, Textarea } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { + isValidImportMnemonic, + normalizeMnemonic, +} from "@shared/api/helpers/mnemonic"; +import { ACCOUNT_ALREADY_EXISTS_ERROR } from "@shared/constants/errors"; +import { emitMetric } from "helpers/metrics"; + +import { AppDispatch } from "popup/App"; +import { ROUTES } from "popup/constants/routes"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { navigateTo } from "popup/helpers/navigate"; +import { + clearApiError, + importRecoveryPhrase, +} from "popup/ducks/accountServices"; + +import { SubviewHeader } from "popup/components/SubviewHeader"; +import { ScreenTitle } from "popup/components/ScreenTitle"; +import { View } from "popup/basics/layout/View"; +import { EnterPassword } from "popup/components/EnterPassword"; + +import "./styles.scss"; + +/* + Import a further recovery phrase into the wallet. + + phrase: the textarea, checked in the popup before anything else happens. + password: re-authentication before the background stores anything. + + The phrase lives in component state between the steps and is dropped on + success and when the screen goes away. +*/ +type Step = "phrase" | "password"; + +export const ImportRecoveryPhrase = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [step, setStep] = useState("phrase"); + const [phrase, setPhrase] = useState(""); + const [phraseError, setPhraseError] = useState(""); + // EnterPassword's button is disabled while loading, but Enter in the + // field still submits the form. Two concurrent imports could store the + // same accounts twice. + const isImporting = useRef(false); + + useEffect( + () => () => { + // Clear any import error so it doesn't show on the next visit. + dispatch(clearApiError()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const formatAuthError = (error: string) => + error === ACCOUNT_ALREADY_EXISTS_ERROR + ? t("This account is already in your wallet.") + : error; + + // 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, as onboarding + // recovery does. + const handleKeyboardPaste = async () => { + try { + await navigator.clipboard.writeText(""); + } catch (e) { + // Nothing to do if the clipboard can't be written. + } + }; + + const handleImport = () => { + if (!isValidImportMnemonic(phrase)) { + setPhraseError(t("Invalid mnemonic phrase")); + return; + } + setPhraseError(""); + setStep("password"); + }; + + const handlePassword = async (password: string) => { + if (isImporting.current) { + return; + } + isImporting.current = true; + try { + const res = await dispatch( + importRecoveryPhrase({ + password, + mnemonicPhrase: normalizeMnemonic(phrase), + }), + ); + + if (importRecoveryPhrase.fulfilled.match(res)) { + emitMetric(METRIC_NAMES.accountCreated, { + number_of_accounts: res.payload.allAccounts.length, + source: "imported_phrase", + }); + setPhrase(""); + navigateTo(ROUTES.account, navigate); + } + } finally { + isImporting.current = false; + } + }; + + if (step === "password") { + return ( + + { + dispatch(clearApiError()); + setStep("phrase"); + }} + /> + + + ); + } + + return ( + + { + setPhrase(""); + navigateTo(ROUTES.addWallet, navigate); + }} + /> + + +
+