From 9b0fda9707412fc2163c1a036fc319945534d8d2 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Tue, 10 Nov 2026 11:03:29 -0700 Subject: [PATCH 01/14] feat(settings): add Accounts & recovery with secret reveal Ports stellar/freighter#3057. Settings gets an Accounts & recovery card that lists accounts grouped by the recovery phrase or secret key behind them. Edit account renames an account, opens it on stellar.expert, and reveals its secret key or recovery phrase after a password or biometric check. revealAccountSecretKey and revealRecoveryPhrase in ducks/auth check the session is still authenticated before and after decrypting, and a phrase is only returned for the source the account belongs to. --- .../AccountsRecoveryScreen.test.tsx | 124 ++++++ .../SettingsScreen/EditAccountScreen.test.tsx | 143 +++++++ .../RevealSecretScreen.test.tsx | 188 +++++++++ .../SettingsScreen/SettingsScreen.test.tsx | 14 + __tests__/ducks/auth.test.ts | 230 +++++++++++ __tests__/helpers/accountGroups.test.ts | 138 +++++++ .../images/accounts-card-avatar-back.png | Bin 0 -> 3241 bytes .../images/accounts-card-avatar-back@2x.png | Bin 0 -> 6885 bytes .../images/accounts-card-avatar-back@3x.png | Bin 0 -> 11988 bytes .../images/accounts-card-avatar-front.png | Bin 0 -> 2358 bytes .../images/accounts-card-avatar-front@2x.png | Bin 0 -> 5700 bytes .../images/accounts-card-avatar-front@3x.png | Bin 0 -> 11040 bytes src/assets/images/index.ts | 4 + .../AccountsRecoveryScreen.tsx | 232 +++++++++++ .../AccountsRecoveryScreen/index.ts | 1 + .../EditAccountScreen/EditAccountScreen.tsx | 204 ++++++++++ .../SettingsScreen/EditAccountScreen/index.ts | 1 + .../RevealSecretScreen/RevealSecretScreen.tsx | 368 ++++++++++++++++++ .../RevealSecretScreen/index.ts | 1 + .../screens/SettingsScreen/SettingsScreen.tsx | 59 ++- src/config/analyticsConfig.ts | 25 ++ src/config/routes.ts | 18 + src/ducks/auth.ts | 118 ++++++ src/helpers/accountGroups.ts | 86 ++++ src/i18n/locales/en/translations.json | 43 +- src/i18n/locales/pt/translations.json | 43 +- src/navigators/SettingsNavigator.tsx | 21 + 27 files changed, 2055 insertions(+), 6 deletions(-) create mode 100644 __tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx create mode 100644 __tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx create mode 100644 __tests__/components/screens/SettingsScreen/RevealSecretScreen.test.tsx create mode 100644 __tests__/helpers/accountGroups.test.ts create mode 100644 src/assets/images/accounts-card-avatar-back.png create mode 100644 src/assets/images/accounts-card-avatar-back@2x.png create mode 100644 src/assets/images/accounts-card-avatar-back@3x.png create mode 100644 src/assets/images/accounts-card-avatar-front.png create mode 100644 src/assets/images/accounts-card-avatar-front@2x.png create mode 100644 src/assets/images/accounts-card-avatar-front@3x.png create mode 100644 src/components/screens/SettingsScreen/AccountsRecoveryScreen/AccountsRecoveryScreen.tsx create mode 100644 src/components/screens/SettingsScreen/AccountsRecoveryScreen/index.ts create mode 100644 src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx create mode 100644 src/components/screens/SettingsScreen/EditAccountScreen/index.ts create mode 100644 src/components/screens/SettingsScreen/RevealSecretScreen/RevealSecretScreen.tsx create mode 100644 src/components/screens/SettingsScreen/RevealSecretScreen/index.ts create mode 100644 src/helpers/accountGroups.ts diff --git a/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx b/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx new file mode 100644 index 000000000..b43e7530d --- /dev/null +++ b/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx @@ -0,0 +1,124 @@ +import { fireEvent, waitFor, within } from "@testing-library/react-native"; +import AccountsRecoveryScreen from "components/screens/SettingsScreen/AccountsRecoveryScreen"; +import { SETTINGS_ROUTES } from "config/routes"; +import { Account } from "config/types"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +jest.mock("@react-navigation/native", () => { + const { useEffect } = jest.requireActual("react"); + return { + ...jest.requireActual("@react-navigation/native"), + useFocusEffect: (effect: () => void) => useEffect(effect, [effect]), + }; +}); + +jest.mock("services/auth/secretSources", () => ({ + getSecretSources: jest.fn().mockResolvedValue({ + "key-1": { + id: "key-1", + type: "secret_key", + label: "Secret Key 1", + createdAt: 2, + }, + "phrase-1": { + id: "phrase-1", + type: "phrase", + label: "Seed Phrase 1", + createdAt: 1, + }, + }), +})); + +const accounts: Account[] = [ + { id: "a", sourceId: "phrase-1", name: "Account 1", publicKey: "GA" }, + { id: "b", sourceId: "key-1", name: "Imported", publicKey: "GB" }, + { id: "c", sourceId: "phrase-1", name: "Account 2", publicKey: "GC" }, +]; + +const mockGetAllAccounts = jest.fn(); +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: () => ({ + allAccounts: accounts, + getAllAccounts: mockGetAllAccounts, + network: "PUBLIC", + }), +})); + +jest.mock("hooks/useGetActiveAccount", () => ({ + __esModule: true, + default: () => ({ account: { id: "c", publicKey: "GC" } }), +})); + +const mockFetchTotals = jest.fn(); +jest.mock("ducks/accountsFiatTotals", () => ({ + useAccountsFiatTotalsStore: ( + selector: (state: Record) => unknown, + ) => + selector({ + fiatTotals: { GA: { label: "$10.00", hasError: false } }, + isLoading: false, + fetchAccountsFiatTotals: mockFetchTotals, + }), +})); + +const navigation = { navigate: jest.fn() }; + +const renderScreen = () => + renderWithProviders( + , + ); + +describe("AccountsRecoveryScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("groups accounts under their phrase, then their key", async () => { + const screen = renderScreen(); + + await waitFor(() => + expect(screen.getAllByTestId("accounts-recovery-group")).toHaveLength(2), + ); + const [phraseGroup, keyGroup] = screen.getAllByTestId( + "accounts-recovery-group", + ); + + expect(within(phraseGroup).getByText("Recovery phrase 1")).toBeTruthy(); + expect(within(phraseGroup).getByText("Account 1")).toBeTruthy(); + expect(within(phraseGroup).getByText("Account 2")).toBeTruthy(); + expect(within(keyGroup).getByText("Secret key 1")).toBeTruthy(); + expect(within(keyGroup).getByText("Imported")).toBeTruthy(); + }); + + it("shows totals and marks the active account", async () => { + const screen = renderScreen(); + + await waitFor(() => expect(screen.getByText("$10.00")).toBeTruthy()); + expect( + screen.getAllByTestId("accounts-recovery-selected-badge"), + ).toHaveLength(1); + expect(mockFetchTotals).toHaveBeenCalledWith({ + publicKeys: ["GA", "GB", "GC"], + network: "PUBLIC", + excludePublicKey: "GC", + }); + }); + + it("opens Edit account for a row", async () => { + const screen = renderScreen(); + + await waitFor(() => + expect(screen.getAllByTestId("accounts-recovery-group")).toHaveLength(2), + ); + fireEvent.press(screen.getByLabelText("Edit Imported")); + + expect(navigation.navigate).toHaveBeenCalledWith( + SETTINGS_ROUTES.EDIT_ACCOUNT_SCREEN, + { accountId: "b" }, + ); + }); +}); diff --git a/__tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx b/__tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx new file mode 100644 index 000000000..354746185 --- /dev/null +++ b/__tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx @@ -0,0 +1,143 @@ +import { fireEvent, waitFor } from "@testing-library/react-native"; +import EditAccountScreen from "components/screens/SettingsScreen/EditAccountScreen"; +import { NETWORKS } from "config/constants"; +import { SETTINGS_ROUTES } from "config/routes"; +import { Account } from "config/types"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +jest.mock("services/analytics", () => ({ + analytics: { track: jest.fn(), trackViewPublicKeyAccountRenamed: jest.fn() }, +})); + +const mockOpenInAppBrowser = jest.fn().mockResolvedValue(undefined); +jest.mock("hooks/useInAppBrowser", () => ({ + useInAppBrowser: () => ({ open: mockOpenInAppBrowser }), +})); + +jest.mock("components/screens/HomeScreen/RenameAccountModal", () => { + const { Text } = jest.requireActual("react-native"); + return ({ modalVisible }: { modalVisible: boolean }) => + modalVisible ? rename : null; +}); + +jest.mock("services/auth/secretSources", () => ({ + getSecretSources: jest.fn().mockResolvedValue({ + "phrase-1": { + id: "phrase-1", + type: "phrase", + label: "Seed Phrase 1", + createdAt: 1, + }, + "key-1": { + id: "key-1", + type: "secret_key", + label: "Secret Key 1", + createdAt: 2, + }, + }), +})); + +const mockDerived: Account = { + id: "derived-id", + sourceId: "phrase-1", + name: "Account 1", + publicKey: "GDERIVED", +}; +const mockImported: Account = { + id: "imported-id", + sourceId: "key-1", + name: "Imported", + publicKey: "GIMPORTED", + importedFromSecretKey: true, +}; + +let mockNetwork = NETWORKS.PUBLIC; +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: () => ({ + allAccounts: [mockDerived, mockImported], + network: mockNetwork, + renameAccount: jest.fn(), + isRenamingAccount: false, + }), +})); + +const navigation = { navigate: jest.fn(), goBack: jest.fn() }; + +const renderScreen = (accountId: string) => + renderWithProviders( + , + ); + +describe("EditAccountScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockNetwork = NETWORKS.PUBLIC; + }); + + it("offers a phrase-derived account its phrase and its own key", async () => { + const screen = renderScreen(mockDerived.id); + + await waitFor(() => + expect(screen.getByTestId("edit-account-recovery-phrase")).toBeTruthy(), + ); + expect(screen.getByText("Recovery phrase 1")).toBeTruthy(); + + fireEvent.press(screen.getByTestId("edit-account-recovery-phrase")); + expect(navigation.navigate).toHaveBeenLastCalledWith( + SETTINGS_ROUTES.REVEAL_SECRET_SCREEN, + { kind: "recoveryPhrase", sourceId: "phrase-1" }, + ); + + fireEvent.press(screen.getByTestId("edit-account-secret-key")); + expect(navigation.navigate).toHaveBeenLastCalledWith( + SETTINGS_ROUTES.REVEAL_SECRET_SCREEN, + { kind: "secretKey", accountId: mockDerived.id }, + ); + }); + + it("offers an imported account only its key", async () => { + const screen = renderScreen(mockImported.id); + + await waitFor(() => + expect(screen.getByTestId("edit-account-secret-key")).toBeTruthy(), + ); + expect(screen.queryByTestId("edit-account-recovery-phrase")).toBeNull(); + }); + + it("opens the rename sheet from the account name", () => { + const screen = renderScreen(mockDerived.id); + + expect(screen.getByText("Account 1")).toBeTruthy(); + fireEvent.press(screen.getByTestId("edit-account-name")); + + expect(screen.getByTestId("rename-modal")).toBeTruthy(); + }); + + it("links the account on stellar.expert for the current network", () => { + mockNetwork = NETWORKS.TESTNET; + const screen = renderScreen(mockDerived.id); + + fireEvent.press(screen.getByTestId("edit-account-explorer")); + + expect(mockOpenInAppBrowser).toHaveBeenCalledWith( + expect.stringMatching(/\/testnet\/account\/GDERIVED$/), + ); + }); + + it("hides the explorer link on futurenet", () => { + mockNetwork = NETWORKS.FUTURENET; + const screen = renderScreen(mockDerived.id); + + expect(screen.queryByTestId("edit-account-explorer")).toBeNull(); + }); + + it("goes back when the account is gone", () => { + renderScreen("missing-id"); + + expect(navigation.goBack).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/screens/SettingsScreen/RevealSecretScreen.test.tsx b/__tests__/components/screens/SettingsScreen/RevealSecretScreen.test.tsx new file mode 100644 index 000000000..50f66c2a7 --- /dev/null +++ b/__tests__/components/screens/SettingsScreen/RevealSecretScreen.test.tsx @@ -0,0 +1,188 @@ +import { act, fireEvent, waitFor } from "@testing-library/react-native"; +import RevealSecretScreen from "components/screens/SettingsScreen/RevealSecretScreen"; +import { AnalyticsEvent } from "config/analyticsConfig"; +import { RevealSecretParams } from "config/routes"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; +import { track } from "services/analytics/core"; + +jest.mock("components/sds/BiometricToggleButton", () => ({ + BiometricToggleButton: () => null, +})); + +jest.mock("services/analytics/core", () => ({ + track: jest.fn(), +})); + +jest.mock("services/analytics", () => ({ + analytics: { trackCopyBackupPhrase: jest.fn() }, +})); + +const mockCopyToClipboard = jest.fn(); +jest.mock("hooks/useSecureClipboard", () => ({ + useSecureClipboard: () => ({ copyToClipboard: mockCopyToClipboard }), +})); + +const mockRevealAccountSecretKey = jest.fn(); +const mockRevealRecoveryPhrase = jest.fn(); +jest.mock("ducks/auth", () => ({ + revealAccountSecretKey: (...args: unknown[]) => + mockRevealAccountSecretKey(...args), + revealRecoveryPhrase: (...args: unknown[]) => + mockRevealRecoveryPhrase(...args), + // The biometric button runs the action straight away with no stored + // password, as it does when biometrics are off. + useAuthenticationStore: () => ({ + signInMethod: "password", + verifyActionWithBiometrics: (callback: (password?: string) => unknown) => + callback(undefined), + }), +})); + +jest.mock("hooks/useBiometrics", () => ({ + useBiometrics: () => ({ getBiometricButtonIcon: () => null }), +})); + +const PHRASE = + "abandon ability able about above absent absorb abstract absurd abuse access accident"; +const SECRET = "SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ"; + +const navigation = { setOptions: jest.fn(), goBack: jest.fn() }; + +const renderScreen = (params: RevealSecretParams) => + renderWithProviders( + , + ); + +const passConsent = ( + screen: ReturnType, + testID: string, +) => { + fireEvent.press(screen.getByTestId(`${testID}-acknowledge`)); + fireEvent.press(screen.getByTestId(`${testID}-continue`)); +}; + +const submitPassword = async ( + screen: ReturnType, + testID: string, + password: string, +) => { + fireEvent.changeText(screen.getByTestId(`${testID}-password`), password); + fireEvent.press(screen.getByTestId(`${testID}-submit`)); + // Lets the reveal's promise settle and the step change render. + await act(() => Promise.resolve()); +}; + +describe("RevealSecretScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("secret key", () => { + const params: RevealSecretParams = { + kind: "secretKey", + accountId: "account-1", + }; + + it("asks for consent before the password", () => { + const screen = renderScreen(params); + + expect( + screen.getByText("Your secret key controls this wallet"), + ).toBeTruthy(); + expect( + screen.getByTestId("reveal-secret-key-continue").props + .accessibilityState?.disabled, + ).toBe(true); + + passConsent(screen, "reveal-secret-key"); + + expect(screen.getByTestId("reveal-secret-key-password")).toBeTruthy(); + expect(mockRevealAccountSecretKey).not.toHaveBeenCalled(); + }); + + it("reveals the account's key after the password and copies it", async () => { + mockRevealAccountSecretKey.mockResolvedValue(SECRET); + const screen = renderScreen(params); + + passConsent(screen, "reveal-secret-key"); + await submitPassword(screen, "reveal-secret-key", "hunter2"); + + expect(mockRevealAccountSecretKey).toHaveBeenCalledWith({ + accountId: "account-1", + password: "hunter2", + }); + expect(screen.getByTestId("reveal-secret-key-value")).toHaveTextContent( + SECRET, + ); + + fireEvent.press(screen.getByTestId("reveal-secret-key-copy")); + expect(mockCopyToClipboard).toHaveBeenCalledWith(SECRET); + }); + + it("stays on the password step with the error for a wrong password", async () => { + mockRevealAccountSecretKey.mockRejectedValue( + new Error("Invalid password. Please try again."), + ); + const screen = renderScreen(params); + + passConsent(screen, "reveal-secret-key"); + await submitPassword(screen, "reveal-secret-key", "wrong"); + + expect( + screen.getByText("Invalid password. Please try again."), + ).toBeTruthy(); + expect(screen.queryByTestId("reveal-secret-key-value")).toBeNull(); + }); + + // A reveal is counted only once the password is accepted. + it("names the screen from the step it's on", async () => { + mockRevealAccountSecretKey.mockResolvedValue(SECRET); + const screen = renderScreen(params); + + expect(track).toHaveBeenLastCalledWith( + AnalyticsEvent.SCREEN_VIEWED, + expect.objectContaining({ screen_name: "unlock_secret_key" }), + ); + + passConsent(screen, "reveal-secret-key"); + await submitPassword(screen, "reveal-secret-key", "hunter2"); + + await waitFor(() => + expect(track).toHaveBeenLastCalledWith( + AnalyticsEvent.SCREEN_VIEWED, + expect.objectContaining({ + screen_name: "show_secret_key", + flow: "security", + }), + ), + ); + }); + }); + + describe("recovery phrase", () => { + it("reveals the named source's phrase as a numbered grid", async () => { + mockRevealRecoveryPhrase.mockResolvedValue(PHRASE); + const screen = renderScreen({ + kind: "recoveryPhrase", + sourceId: "phrase-1", + }); + + passConsent(screen, "reveal-recovery-phrase"); + await submitPassword(screen, "reveal-recovery-phrase", "hunter2"); + + expect(mockRevealRecoveryPhrase).toHaveBeenCalledWith({ + password: "hunter2", + sourceId: "phrase-1", + }); + expect(screen.getByTestId("reveal-recovery-phrase-value")).toBeTruthy(); + expect(screen.getByText("accident")).toBeTruthy(); + + fireEvent.press(screen.getByTestId("reveal-recovery-phrase-copy")); + expect(mockCopyToClipboard).toHaveBeenCalledWith(PHRASE); + }); + }); +}); diff --git a/__tests__/components/screens/SettingsScreen/SettingsScreen.test.tsx b/__tests__/components/screens/SettingsScreen/SettingsScreen.test.tsx index f566a7f13..5cf13f057 100644 --- a/__tests__/components/screens/SettingsScreen/SettingsScreen.test.tsx +++ b/__tests__/components/screens/SettingsScreen/SettingsScreen.test.tsx @@ -53,8 +53,10 @@ jest.mock("hooks/useAppTranslation", () => ({ }), })); +const mockNavigate = jest.fn(); const mockNavigation = { goBack: mockGoBack, + navigate: mockNavigate, setOptions: jest.fn(), } as unknown as SettingsScreenNavigationProp; @@ -94,4 +96,16 @@ describe("SettingsScreen", () => { const versionText = getByTestId("update-button"); expect(versionText).toHaveTextContent("v1.1 (1)"); }); + + it("opens Accounts & recovery from the card at the top", () => { + const { getByTestId } = renderWithProviders( + , + ); + + fireEvent.press(getByTestId("settings-accounts-recovery")); + + expect(mockNavigate).toHaveBeenCalledWith( + SETTINGS_ROUTES.ACCOUNTS_RECOVERY_SCREEN, + ); + }); }); diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 7cccca899..015746e39 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -21,6 +21,8 @@ import { clearSessionAuthValidMemo, SESSION_AUTH_VALID_TTL_MS, getActiveMnemonicPhrase, + revealAccountSecretKey, + revealRecoveryPhrase, getHeldSecrets, } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; @@ -3767,6 +3769,234 @@ describe("auth duck", () => { }); }); + // Edit account's reveals: the password is checked against a keystore + // even though the session is unlocked. + describe("revealAccountSecretKey", () => { + const importedId = "imported-keystore-id"; + + beforeEach(() => { + mockAuthenticatedSession(mockTemporaryStore); + sharedKeyManager.loadAllKeyIds.mockResolvedValue([ + mockAccountId, + importedId, + ]); + sharedKeyManager.loadKey.mockImplementation( + (id: string, password: string) => { + if (password !== mockPassword) { + return Promise.reject(new Error("bad password")); + } + return Promise.resolve( + id === importedId + ? { + id, + publicKey: importedPublicKey, + privateKey: importedSecretKey, + } + : { id, publicKey: mockPublicKey, privateKey: mockPrivateKey }, + ); + }, + ); + }); + + it("returns each account's own secret key from its own keystore", async () => { + expect( + await revealAccountSecretKey({ + accountId: importedId, + password: mockPassword, + }), + ).toBe(importedSecretKey); + expect(sharedKeyManager.loadKey).toHaveBeenLastCalledWith( + importedId, + mockPassword, + ); + + expect( + await revealAccountSecretKey({ + accountId: mockAccountId, + password: mockPassword, + }), + ).toBe(mockPrivateKey); + }); + + it("refuses a wrong password while the wallet is unlocked", async () => { + await expect( + revealAccountSecretKey({ + accountId: importedId, + password: "wrong-password", + }), + ).rejects.toThrow("authStore.error.invalidPassword"); + }); + + it("never opens a keystore the key manager doesn't list", async () => { + await expect( + revealAccountSecretKey({ + accountId: "not-a-key-id", + password: mockPassword, + }), + ).rejects.toThrow("authStore.error.noKeyPairFound"); + expect(sharedKeyManager.loadKey).not.toHaveBeenCalled(); + }); + + it("refuses while the wallet is locked", async () => { + act(() => { + useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED }); + }); + + await expect( + revealAccountSecretKey({ + accountId: importedId, + password: mockPassword, + }), + ).rejects.toThrow("authStore.error.authenticationExpired"); + expect(sharedKeyManager.loadKey).not.toHaveBeenCalled(); + }); + + it("withholds the key if a lock lands during the decrypt", async () => { + sharedKeyManager.loadKey.mockImplementation(() => { + useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED }); + return Promise.resolve({ + id: importedId, + privateKey: importedSecretKey, + }); + }); + + await expect( + revealAccountSecretKey({ + accountId: importedId, + password: mockPassword, + }), + ).rejects.toThrow("authStore.error.authenticationExpired"); + }); + }); + + describe("revealRecoveryPhrase", () => { + const secretKeySourceId = "secret-key-source"; + + beforeEach(() => { + dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES] = JSON.stringify({ + [mockPhraseSourceId]: { + id: mockPhraseSourceId, + type: SecretSourceType.PHRASE, + label: "Seed Phrase 1", + createdAt: 1, + }, + [secretKeySourceId]: { + id: secretKeySourceId, + type: SecretSourceType.SECRET_KEY, + label: "Secret Key 1", + createdAt: 2, + }, + }); + dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([ + { + id: mockAccountId, + name: "Account 1", + publicKey: mockPublicKey, + sourceId: mockPhraseSourceId, + }, + ]); + sharedKeyManager.loadKey.mockImplementation( + (id: string, password: string) => + password === mockPassword + ? Promise.resolve({ + id, + publicKey: mockPublicKey, + privateKey: mockPrivateKey, + extra: { mnemonicPhrase: mockMnemonicPhrase }, + }) + : Promise.reject(new Error("bad password")), + ); + }); + + it("returns the held phrase of the named source", async () => { + mockAuthenticatedSession( + JSON.stringify({ + privateKeys: {}, + mnemonics: { [mockPhraseSourceId]: "held phrase" }, + }), + ); + + expect( + await revealRecoveryPhrase({ + password: mockPassword, + sourceId: mockPhraseSourceId, + }), + ).toBe("held phrase"); + expect(sharedKeyManager.loadKey).toHaveBeenCalledWith( + mockAccountId, + mockPassword, + ); + }); + + it("reads the phrase from the source's keystore when the session doesn't hold it", async () => { + mockAuthenticatedSession( + JSON.stringify({ privateKeys: {}, mnemonics: {} }), + ); + + expect( + await revealRecoveryPhrase({ + password: mockPassword, + sourceId: mockPhraseSourceId, + }), + ).toBe(mockMnemonicPhrase); + }); + + it("refuses a wrong password while the wallet is unlocked", async () => { + mockAuthenticatedSession(mockTemporaryStore); + + await expect( + revealRecoveryPhrase({ + password: "wrong-password", + sourceId: mockPhraseSourceId, + }), + ).rejects.toThrow("authStore.error.invalidPassword"); + }); + + it("refuses a source that isn't a recorded phrase", async () => { + mockAuthenticatedSession(mockTemporaryStore); + + await expect( + revealRecoveryPhrase({ + password: mockPassword, + sourceId: secretKeySourceId, + }), + ).rejects.toThrow("authStore.error.recoveryPhraseNotFound"); + await expect( + revealRecoveryPhrase({ + password: mockPassword, + sourceId: "unknown-source", + }), + ).rejects.toThrow("authStore.error.recoveryPhraseNotFound"); + expect(sharedKeyManager.loadKey).not.toHaveBeenCalled(); + }); + + it("returns the default phrase when no source is named", async () => { + mockAuthenticatedSession( + JSON.stringify({ + privateKeys: {}, + mnemonics: { [mockPhraseSourceId]: "held phrase" }, + }), + ); + + expect(await revealRecoveryPhrase({ password: mockPassword })).toBe( + "held phrase", + ); + }); + + it("refuses while the wallet is locked", async () => { + act(() => { + useAuthenticationStore.setState({ authStatus: AUTH_STATUS.LOCKED }); + }); + + await expect( + revealRecoveryPhrase({ + password: mockPassword, + sourceId: mockPhraseSourceId, + }), + ).rejects.toThrow("authStore.error.authenticationExpired"); + }); + }); + it("derives a missing private key from any held phrase on account switch", async () => { const secondAccount: Account = { id: "account-2", diff --git a/__tests__/helpers/accountGroups.test.ts b/__tests__/helpers/accountGroups.test.ts new file mode 100644 index 000000000..6fbd09595 --- /dev/null +++ b/__tests__/helpers/accountGroups.test.ts @@ -0,0 +1,138 @@ +import { Account, SecretSource, SecretSourceType } from "config/types"; +import { + AccountGroupKind, + getSourceLabel, + groupAccountsBySource, +} from "helpers/accountGroups"; +import { TFunction } from "i18next"; + +const t = ((key: string, options?: { number?: string }) => + options?.number + ? `${key}:${options.number}` + : key) as TFunction<"translations">; + +const phrase = ( + id: string, + label: string, + createdAt: number, +): SecretSource => ({ + id, + type: SecretSourceType.PHRASE, + label, + createdAt, +}); +const secretKey = ( + id: string, + label: string, + createdAt: number, +): SecretSource => ({ + id, + type: SecretSourceType.SECRET_KEY, + label, + createdAt, +}); +const account = (id: string, sourceId?: string): Account => ({ + id, + sourceId, + name: id, + publicKey: `G${id}`, +}); + +describe("getSourceLabel", () => { + it("keeps the stored number and translates the rest", () => { + expect(getSourceLabel(phrase("p", "Seed Phrase 2", 0), t)).toBe( + "accountsRecoveryScreen.recoveryPhraseLabel:2", + ); + expect(getSourceLabel(secretKey("k", "Secret Key 3", 0), t)).toBe( + "accountsRecoveryScreen.secretKeyLabel:3", + ); + }); + + it("shows a label with no number as stored", () => { + expect(getSourceLabel(phrase("p", "My phrase", 0), t)).toBe("My phrase"); + }); +}); + +describe("groupAccountsBySource", () => { + const sources = [ + secretKey("key-1", "Secret Key 1", 2), + phrase("phrase-2", "Seed Phrase 2", 3), + phrase("phrase-1", "Seed Phrase 1", 1), + ]; + + it("lists phrases before keys, each oldest first", () => { + const groups = groupAccountsBySource({ + accounts: [ + account("a", "phrase-1"), + account("b", "phrase-2"), + account("c", "key-1"), + ], + sources, + t, + }); + + expect(groups.map(({ key, kind }) => [key, kind])).toEqual([ + ["phrase-1", AccountGroupKind.PHRASE], + ["phrase-2", AccountGroupKind.PHRASE], + ["key-1", AccountGroupKind.SECRET_KEY], + ]); + }); + + it("keeps each account under its own source, in list order", () => { + const [first] = groupAccountsBySource({ + accounts: [ + account("a", "phrase-1"), + account("b", "phrase-2"), + account("c", "phrase-1"), + ], + sources, + t, + }); + + expect(first.accounts.map(({ id }) => id)).toEqual(["a", "c"]); + expect(first.sourceId).toBe("phrase-1"); + }); + + it("puts accounts with no known source in a trailing group", () => { + const groups = groupAccountsBySource({ + accounts: [ + account("a", "phrase-1"), + account("legacy"), + account("orphan", "removed-source"), + ], + sources, + t, + }); + + const other = groups[groups.length - 1]; + expect(other.kind).toBe(AccountGroupKind.OTHER); + expect(other.label).toBe("accountsRecoveryScreen.otherAccounts"); + expect(other.sourceId).toBeUndefined(); + expect(other.accounts.map(({ id }) => id)).toEqual(["legacy", "orphan"]); + }); + + it("leaves out sources with no accounts", () => { + const groups = groupAccountsBySource({ + accounts: [account("c", "key-1")], + sources, + t, + }); + + expect(groups.map(({ key }) => key)).toEqual(["key-1"]); + }); + + it("lists every account exactly once", () => { + const accounts = [ + account("a", "phrase-1"), + account("b", "key-1"), + account("c"), + account("d", "phrase-2"), + ]; + const ids = groupAccountsBySource({ accounts, sources, t }) + .flatMap((group) => group.accounts) + .map(({ id }) => id) + .sort(); + + expect(ids).toEqual(["a", "b", "c", "d"]); + }); +}); diff --git a/src/assets/images/accounts-card-avatar-back.png b/src/assets/images/accounts-card-avatar-back.png new file mode 100644 index 0000000000000000000000000000000000000000..c7ffea26b705bb6f08662565ddb3e75ae5137de3 GIT binary patch literal 3241 zcmZ{mc|26@7r^frSu=#JVH&hqW|DO<$`V>MSw|=nV>dI#SdwIvJyJyWQDn(dvddCJ z*^4aIL@BaP#3;f$)UW#WdEfi_oco;Tobx^BJoh<&+(@*EArF@r7XSb}#zy+4%oVyJ z9BjwPsA^Q^EB&iY}@2ga};D3udvoZw~o!F6_*@YE7loZ zMJ>EFX}4}zUypedHL<@SGeNY)PRc0n$#O=wEM4Ggb(x^o(bDbnksnA;$0^Qr7-;=2 zOH5}TUjyIWkCL>>cQrKo=Md6L*@Q>oGmQiR%oB(faS+jSnMGnq=lcUnL$Do5?VV|6 zs950Svum*3H=nc<8xi0e7`-slc)!?LKVjO5nDR@z?X#pWS4EWHfDly;ce}T~>eE6f zvD%hbzZ0YM873E6QU=WIt5~nRMR!+Nb-KM{OlToK?|JfCCJxD3qp%Ydd-;>3*%vTz zA`k@5Ju8r%2(e*)byf#sJ@i%{0E7pR(wX}x9LCt`_;Fw#Q|AC!L1MrbrUqgP3MBqZ zKMdLffH&OoOVXhlb98)%!A1nBJ7{Jcl@iWDr%JM53N(KMcnLgl8EYIDgmwz&1QTu+4M*D5tfN3~6nO^Co!sY_x^iudJ-KS>pe!zZ?8PVE&c( z)A~2@8?eB;l2J-Oi~ME&gWBj9iFWnJd06YadNPM#vpWQ{yMOikO_hnn9`jDRPm>^UqSf!2}CMm3{cMh#VXjy!jh7Cesb zJ!N*X@Pcv<_3|y#(6d>~RL_Kp!-7xP6pF$MBBJ=m-zHZtWq5yI9YBqD9rIvxpjOTo ztuL394``^}u&|g?$}!q&xoavXh{R}Eo?jJM{nkuD=9kn=7wqGHX<-_Xx3AMVhhb|s z@zt5FW^P8CCyBdjpynalUOPzejgq47MVi#<9zC6}NnJXX*$KY+JxWnL)Fe;>CtqIV zsr;0reeKnZx;y>hIz0pIgwtG5i`5stYf`}LH1yg6VXdG$GvM%vbzePNmqnkP#ej4I zi+z0`@Tupq?hs2J$kZU+WIUzQDWIK_ef~r&`EzG5V^~(b0)!ZiZ0P09>=icA&VTvr z;L%muj({yb!fk$34-ue1gJqlspey7t>VX4?3_0UW#qU))m`}bvAGZ9}Fo^;TDnM(l z2sgJie4ELfdNL4H0OL{l1VP(Vgj93Su&|^!cXYZxQg*P4OWnd6{#mTD?_N{Yz%@&& z_}I6q&zqymQ7aSJV3!+;z~DG9>if}j^~JoXTfBf-xuQnpbZXfA+@x5)chw?R+adU6na@Wq z@td|P6w8&Mq4ASN38*%l(6mrxu`j2m?pH^PnpHDl6-le$(z(IFv1`r?ebwVCpGFZP z_XJmHZAF5`6JRPx(47s0Hk0s3agS|jEGAYK0WR$MW}mE19QRqy+Mg01#o3>U`jTBk z`+Qi44=Vp=jG89amKwxQH5HvLb*xozr$$Or3!gvjFqQnMAW3>pVYgJt7@2L&R4@5* z@q29m2ukSE%zBzt&-8mJ%4SmfPJ-|LFX#c=# zJV@Je>G7Gh+a~N-M)`Ip)~AS~7;gUlTA`_9yb)=XC==eChrDjEH^E?ayiDn_hSqms zfIzgUkpLpHsJj6n-Q>IN^8APMQqe=1Z-`^S$jGIj9G@11K`eUa)o2TlM&-I4*gt(U zW|xeTpd4Y>r~7*|g2+Szwe3A^VKqttgfb4oZ94Q=aMM!xRw_F{_nRq*@MzVTGQi6S z5AH#9SHk5UJQmVVq~E9@8-zUNk8tZ1t}nZKWmtYt1u-Jhcoxy?CqK9hfu=1sS|6;n z6O|Y2aIUSa4Y>_!DT>Y!J!md}pXAKW{o!P|O$Wj(Hxu?*p?#)eYpTSf5a&)^F#pHd*6lKJlMqf>4ArN=x3JON zvk^5K$2*{y4M);6&LGl9@+giNPaMjcNF`rUms3JCW5zDOH&~HkEvt%7>0mAV+BLB4 zz%;jL(dns?>jI}~+E(EMv-Z>VL5)Ac{f@dGA9UE;pU z;6XLFTCa#!kqJ4!GgL`n=s4`ne@^GqJj)DIDUWx+IsRN43L9wT*H-o-g7^+;MY` zCvi9-IzfV9e5swJ8(*&AHVScym6UMB$<7-T>CtH=YIc4xs;c)~uwIvZz5Ls&u&`1b zz*quPLu47{HsaH%Ja=}kFRMYw?FwtEAKu#xbIAuz3?4rv%1~LV?AL9))%(=`jY;)l z5B>arh`?Si+?xPxSguCuKw?sS{oJC27`ARnAA<~+s=}Al`%hoskjc_Ns5&kb6I#A4 z{dHkSh^l+N;%UNC>6q=v38g!;wS-)V*2E2;O?3h&m1K?keJ3Te5l9dobSDl(mHtiIb|L+0s$ z^g>CkFPAe`n}+?nzwjK)Sjx3tqfAN_aAcI&PecrJX)31{Q~mP>=&y5Z2z5uKkPqWW z!Xl5VsI@6_H%_s=dk-IPy~xl^|2lbfA(wl7?5GoG^V-mu5MU0zS$vh;nL$IIdw;5K lDjm+-j229iVXb>IdhsM79cdBBD!uXFXl!7jU#x2%@(;Y*e@XxV literal 0 HcmV?d00001 diff --git a/src/assets/images/accounts-card-avatar-back@2x.png b/src/assets/images/accounts-card-avatar-back@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a9e06eae56f67feb86da57e1ef28ae3ba3aff8e1 GIT binary patch literal 6885 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 literal 0 HcmV?d00001 diff --git a/src/assets/images/accounts-card-avatar-front@2x.png b/src/assets/images/accounts-card-avatar-front@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3374fb692165b48d6ac218fa12cffe502f3f0d99 GIT binary patch literal 5700 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 + +interface AccountRowProps { + account: Account; + isSelected: boolean; + fiatTotalLabel?: string; + isLoadingTotal: boolean; + onEdit: (accountId: string) => void; +} + +const AccountRow: React.FC = ({ + account, + isSelected, + fiatTotalLabel, + isLoadingTotal, + onEdit, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + const renderTotal = () => { + if (!fiatTotalLabel && isLoadingTotal) { + return ( + + ); + } + + return ( + + {fiatTotalLabel ?? ""} + + ); + }; + + return ( + + + + {isSelected && ( + + {/* A solid navy circle in both schemes, so the check is white. */} + + + )} + + + + {account.name} + + {renderTotal()} + + onEdit(account.id)} + delayPressIn={DEFAULT_PRESS_DELAY} + accessibilityRole="button" + accessibilityLabel={t("accountsRecoveryScreen.editAccount", { + name: account.name, + })} + hitSlop={8} + testID="accounts-recovery-edit" + > + + + + ); +}; + +const GroupIcon: React.FC<{ kind: AccountGroupKind; color: string }> = ({ + kind, + color, +}) => { + switch (kind) { + case AccountGroupKind.PHRASE: + return ; + case AccountGroupKind.SECRET_KEY: + return ; + default: + return ; + } +}; + +/** + * Every account in the wallet, grouped under the recovery phrase or secret + * key that controls it, with its USD total. The active account is marked, + * and each row opens Edit account. + */ +const AccountsRecoveryScreen: React.FC = ({ + navigation, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const insets = useSafeAreaInsets(); + const { allAccounts, getAllAccounts, network } = useAuthenticationStore(); + const { account: activeAccount } = useGetActiveAccount(); + const fiatTotals = useAccountsFiatTotalsStore((state) => state.fiatTotals); + const isLoadingFiatTotals = useAccountsFiatTotalsStore( + (state) => state.isLoading, + ); + const fetchAccountsFiatTotals = useAccountsFiatTotalsStore( + (state) => state.fetchAccountsFiatTotals, + ); + const [sources, setSources] = useState([]); + + // Refetched on focus so a rename on Edit account shows on return. + useFocusEffect( + useCallback(() => { + getAllAccounts(); + getSecretSources() + .then((map) => setSources(Object.values(map))) + .catch((error) => { + logger.error( + "AccountsRecoveryScreen", + "Failed to read secret sources", + error, + ); + }); + }, [getAllAccounts]), + ); + + useFocusEffect( + useCallback(() => { + if (allAccounts.length === 0) { + return; + } + // TTL-gated, so returning to the screen doesn't refetch every time. + fetchAccountsFiatTotals({ + publicKeys: allAccounts.map(({ publicKey }) => publicKey), + network, + excludePublicKey: activeAccount?.publicKey, + }); + }, [ + allAccounts, + network, + activeAccount?.publicKey, + fetchAccountsFiatTotals, + ]), + ); + + const groups = useMemo( + () => groupAccountsBySource({ accounts: allAccounts, sources, t }), + [allAccounts, sources, t], + ); + + const handleEdit = useCallback( + (accountId: string) => + navigation.navigate(SETTINGS_ROUTES.EDIT_ACCOUNT_SCREEN, { accountId }), + [navigation], + ); + + return ( + + + + {groups.map((group, index) => ( + + {index > 0 && } + + + + + {group.label} + + + {group.accounts.map((account) => ( + + ))} + + + ))} + + + + ); +}; + +export default AccountsRecoveryScreen; diff --git a/src/components/screens/SettingsScreen/AccountsRecoveryScreen/index.ts b/src/components/screens/SettingsScreen/AccountsRecoveryScreen/index.ts new file mode 100644 index 000000000..a84f8a5d8 --- /dev/null +++ b/src/components/screens/SettingsScreen/AccountsRecoveryScreen/index.ts @@ -0,0 +1 @@ +export { default } from "./AccountsRecoveryScreen"; diff --git a/src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx b/src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx new file mode 100644 index 000000000..c4bf48fb4 --- /dev/null +++ b/src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx @@ -0,0 +1,204 @@ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { List, ListItemProps } from "components/List"; +import { BaseLayout } from "components/layout/BaseLayout"; +import RenameAccountModal from "components/screens/HomeScreen/RenameAccountModal"; +import Avatar from "components/sds/Avatar"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { AnalyticsEvent } from "config/analyticsConfig"; +import { NETWORKS } from "config/constants"; +import { logger } from "config/logger"; +import { + RevealSecretParams, + SETTINGS_ROUTES, + SettingsStackParamList, +} from "config/routes"; +import { SecretSource, SecretSourceType } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; +import { getSourceLabel } from "helpers/accountGroups"; +import { getStellarExpertUrl } from "helpers/stellarExpert"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import { useInAppBrowser } from "hooks/useInAppBrowser"; +import React, { useCallback, useEffect, useState } from "react"; +import { ScrollView, View } from "react-native"; +import { analytics } from "services/analytics"; +import { getSecretSources } from "services/auth/secretSources"; + +type EditAccountScreenProps = NativeStackScreenProps< + SettingsStackParamList, + typeof SETTINGS_ROUTES.EDIT_ACCOUNT_SCREEN +>; + +/** + * One account's settings: its name, the secrets that control it, and its + * explorer link. A phrase-derived account offers its recovery phrase and its + * own secret key; an imported account offers only its key. + * + * Networks and Hide wallet are out of scope. + */ +const EditAccountScreen: React.FC = ({ + navigation, + route, +}) => { + const { accountId } = route.params; + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { open: openInAppBrowser } = useInAppBrowser(); + const { allAccounts, network, renameAccount, isRenamingAccount } = + useAuthenticationStore(); + const [phraseSource, setPhraseSource] = useState(null); + const [isRenameVisible, setIsRenameVisible] = useState(false); + + const account = allAccounts.find(({ id }) => id === accountId) ?? null; + const sourceId = account?.sourceId; + + useEffect(() => { + if (!account) { + navigation.goBack(); + } + }, [account, navigation]); + + // Only the phrase that roots this account is offered. A secret-key import + // is its own source and has no phrase. + useEffect(() => { + if (!sourceId) { + setPhraseSource(null); + return; + } + getSecretSources() + .then((sources) => { + const source = sources[sourceId]; + setPhraseSource( + source?.type === SecretSourceType.PHRASE ? source : null, + ); + }) + .catch((error) => { + logger.error( + "EditAccountScreen", + "Failed to read secret sources", + error, + ); + }); + }, [sourceId]); + + const handleRename = useCallback( + async (accountName: string) => { + if (!account) return; + + analytics.trackViewPublicKeyAccountRenamed(); + await renameAccount({ accountName, publicKey: account.publicKey }); + setIsRenameVisible(false); + }, + [account, renameAccount], + ); + + if (!account) { + return null; + } + + const chevron = ; + const unlockToReveal = ( + + + {t("editAccountScreen.unlockToReveal")} + + {chevron} + + ); + + const nameItems: ListItemProps[] = [ + { + title: t("editAccountScreen.accountName"), + titleColor: themeColors.text.primary, + onPress: () => setIsRenameVisible(true), + trailingContent: ( + + + + {account.name} + + + {chevron} + + ), + testID: "edit-account-name", + }, + ]; + + const openReveal = (params: RevealSecretParams) => + navigation.navigate(SETTINGS_ROUTES.REVEAL_SECRET_SCREEN, params); + + const secretItems: ListItemProps[] = []; + if (phraseSource) { + secretItems.push({ + title: getSourceLabel(phraseSource, t), + titleColor: themeColors.text.primary, + onPress: () => + openReveal({ kind: "recoveryPhrase", sourceId: phraseSource.id }), + trailingContent: unlockToReveal, + testID: "edit-account-recovery-phrase", + }); + } + secretItems.push({ + title: t("editAccountScreen.secretKey"), + titleColor: themeColors.text.primary, + onPress: () => openReveal({ kind: "secretKey", accountId: account.id }), + trailingContent: unlockToReveal, + testID: "edit-account-secret-key", + }); + + // stellar.expert has no futurenet explorer. + const explorerItems: ListItemProps[] = + network === NETWORKS.FUTURENET + ? [] + : [ + { + title: t("editAccountScreen.viewOnExplorer"), + titleColor: themeColors.text.primary, + onPress: () => { + analytics.track( + AnalyticsEvent.VIEW_PUBLIC_KEY_CLICKED_STELLAR_EXPERT, + ); + openInAppBrowser( + `${getStellarExpertUrl(network)}/account/${account.publicKey}`, + ).catch((error) => + logger.warn( + "EditAccountScreen", + "Error opening account on stellar.expert:", + error, + ), + ); + }, + trailingContent: ( + + ), + testID: "edit-account-explorer", + }, + ]; + + return ( + + + + + + + + + {explorerItems.length > 0 && } + + + + + + ); +}; + +export default EditAccountScreen; diff --git a/src/components/screens/SettingsScreen/EditAccountScreen/index.ts b/src/components/screens/SettingsScreen/EditAccountScreen/index.ts new file mode 100644 index 000000000..b794dcf0f --- /dev/null +++ b/src/components/screens/SettingsScreen/EditAccountScreen/index.ts @@ -0,0 +1 @@ +export { default } from "./EditAccountScreen"; diff --git a/src/components/screens/SettingsScreen/RevealSecretScreen/RevealSecretScreen.tsx b/src/components/screens/SettingsScreen/RevealSecretScreen/RevealSecretScreen.tsx new file mode 100644 index 000000000..78d8e6a90 --- /dev/null +++ b/src/components/screens/SettingsScreen/RevealSecretScreen/RevealSecretScreen.tsx @@ -0,0 +1,368 @@ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { FOCUS_RING_WIDTH } from "components/FocusRing"; +import { PasswordField } from "components/PasswordField"; +import { RecoveryPhraseGrid } from "components/RecoveryPhraseGrid"; +import { BaseLayout } from "components/layout/BaseLayout"; +import { BiometricToggleButton } from "components/sds/BiometricToggleButton"; +import { Button, IconPosition } from "components/sds/Button"; +import { Checkbox } from "components/sds/Checkbox"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { AnalyticsEvent, buildScreenViewedProps } from "config/analyticsConfig"; +import { LoginType } from "config/constants"; +import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; +import { + revealAccountSecretKey, + revealRecoveryPhrase, + useAuthenticationStore, +} from "ducks/auth"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import { useSecureClipboard } from "hooks/useSecureClipboard"; +import React, { useEffect, useState } from "react"; +import { Trans } from "react-i18next"; +import { ScrollView, StyleSheet, View } from "react-native"; +import { analytics } from "services/analytics"; +import { track } from "services/analytics/core"; + +type RevealSecretScreenProps = NativeStackScreenProps< + SettingsStackParamList, + typeof SETTINGS_ROUTES.REVEAL_SECRET_SCREEN +>; + +/* + One reveal flow for both secrets an account can offer from Edit account. + + consent: the warning and the acknowledgement. + password: re-authentication, with biometrics when they're on. + revealed: the secret and Tap to copy. +*/ +type Step = "consent" | "password" | "revealed"; + +// See ShowRecoveryPhraseScreen: the scroll view reaches past the page padding +// by the focus ring's width so the password field's ring keeps its sides. +const styles = StyleSheet.create({ + scroll: { marginHorizontal: -FOCUS_RING_WIDTH }, + scrollContent: { paddingHorizontal: FOCUS_RING_WIDTH, flexGrow: 1 }, +}); + +/** The red "Never share" card every step opens with. */ +const NeverShareBanner: React.FC<{ title: string; body: string }> = ({ + title, + body, +}) => { + const { themeColors } = useColors(); + + return ( + + + + + {title} + + + + {body} + + + ); +}; + +const RevealSecretScreen: React.FC = ({ + navigation, + route, +}) => { + const { params } = route; + const isPhrase = params.kind === "recoveryPhrase"; + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { signInMethod } = useAuthenticationStore(); + const { copyToClipboard } = useSecureClipboard(); + const [step, setStep] = useState("consent"); + const [hasAcknowledged, setHasAcknowledged] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [secret, setSecret] = useState(""); + + // Each key is written out in full so the translation lint can find it. + const copy = isPhrase + ? { + title: t("revealSecretScreen.recoveryPhrase.title"), + revealedTitle: t("revealSecretScreen.recoveryPhrase.revealedTitle"), + bannerTitle: t("revealSecretScreen.recoveryPhrase.bannerTitle"), + revealedBannerBody: t( + "revealSecretScreen.recoveryPhrase.revealedBannerBody", + ), + heading: t("revealSecretScreen.recoveryPhrase.heading"), + acknowledgementKey: + "revealSecretScreen.recoveryPhrase.acknowledgement" as const, + action: t("revealSecretScreen.recoveryPhrase.action"), + testID: "reveal-recovery-phrase", + screens: [ + AnalyticsEvent.VIEW_UNLOCK_RECOVERY_PHRASE, + AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE, + ], + } + : { + title: t("revealSecretScreen.secretKey.title"), + revealedTitle: t("revealSecretScreen.secretKey.revealedTitle"), + bannerTitle: t("revealSecretScreen.secretKey.bannerTitle"), + revealedBannerBody: t( + "revealSecretScreen.secretKey.revealedBannerBody", + ), + heading: t("revealSecretScreen.secretKey.heading"), + acknowledgementKey: + "revealSecretScreen.secretKey.acknowledgement" as const, + action: t("revealSecretScreen.secretKey.action"), + testID: "reveal-secret-key", + screens: [ + AnalyticsEvent.VIEW_UNLOCK_SECRET_KEY, + AnalyticsEvent.VIEW_SHOW_SECRET_KEY, + ], + }; + + useEffect(() => { + navigation.setOptions({ + headerTitle: step === "revealed" ? copy.revealedTitle : copy.title, + }); + }, [navigation, step, copy.revealedTitle, copy.title]); + + // The route doesn't auto-track: a reveal is only counted once the password + // is accepted, so the screen is named from the step it's on. + const screenName = copy.screens[step === "revealed" ? 1 : 0]; + useEffect(() => { + track(AnalyticsEvent.SCREEN_VIEWED, buildScreenViewedProps(screenName)); + }, [screenName]); + + const reveal = async (passwordToUse: string) => + params.kind === "recoveryPhrase" + ? revealRecoveryPhrase({ + password: passwordToUse, + sourceId: params.sourceId, + }) + : revealAccountSecretKey({ + accountId: params.accountId, + password: passwordToUse, + }); + + // The biometric button passes the stored password; the field's is used + // when biometrics are off. + const handleReveal = async (...args: unknown[]) => { + const passwordToUse = (args[0] as string | undefined) ?? password; + + if (!passwordToUse) { + setError(t("authStore.error.invalidPassword")); + return; + } + + setIsLoading(true); + setError(undefined); + try { + setSecret(await reveal(passwordToUse)); + setPassword(""); + setStep("revealed"); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t("authStore.error.invalidPassword"), + ); + } finally { + setIsLoading(false); + } + }; + + const handleCopy = () => { + if (!secret) return; + + copyToClipboard(secret); + if (isPhrase) { + analytics.trackCopyBackupPhrase(); + } + }; + + if (step === "revealed") { + return ( + + + {/* The phrase ends with Done; the secret key has no buttons at the + bottom and is left with the header's back arrow. */} + + + + {isPhrase ? ( + + + + ) : ( + + + {secret} + + + )} + + + {isPhrase && ( + <> + + + + )} + + + + ); + } + + if (step === "password") { + return ( + + + + + {signInMethod === LoginType.PASSWORD && ( + { + setPassword(text); + setError(undefined); + }} + error={error} + /> + )} + {signInMethod !== LoginType.PASSWORD && error && ( + + {error} + + )} + + + + + + + + ); + } + + return ( + + + + + + + + + + {copy.heading} + + + {t("revealSecretScreen.consentSubtitle")} + + + + + words. + components={{ + strong: ( + + {null} + + ), + }} + /> + + } + testID={`${copy.testID}-acknowledge`} + /> + + + + + ); +}; + +export default RevealSecretScreen; diff --git a/src/components/screens/SettingsScreen/RevealSecretScreen/index.ts b/src/components/screens/SettingsScreen/RevealSecretScreen/index.ts new file mode 100644 index 000000000..4fdf27693 --- /dev/null +++ b/src/components/screens/SettingsScreen/RevealSecretScreen/index.ts @@ -0,0 +1 @@ +export { default } from "./RevealSecretScreen"; diff --git a/src/components/screens/SettingsScreen/SettingsScreen.tsx b/src/components/screens/SettingsScreen/SettingsScreen.tsx index 60068ed89..649440e64 100644 --- a/src/components/screens/SettingsScreen/SettingsScreen.tsx +++ b/src/components/screens/SettingsScreen/SettingsScreen.tsx @@ -1,13 +1,18 @@ /* eslint-disable react/no-unstable-nested-components */ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { images } from "assets/images"; import BottomSheet from "components/BottomSheet"; import { List } from "components/List"; import { BaseLayout } from "components/layout/BaseLayout"; import DeleteAccountBottomSheet from "components/screens/SettingsScreen/DeleteAccountBottomSheet"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; -import { DEFAULT_PADDING, FREIGHTER_BASE_URL } from "config/constants"; +import { + DEFAULT_PADDING, + DEFAULT_PRESS_DELAY, + FREIGHTER_BASE_URL, +} from "config/constants"; import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; import { useAuthenticationStore } from "ducks/auth"; import { pxValue } from "helpers/dimensions"; @@ -16,7 +21,7 @@ import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import { useInAppBrowser } from "hooks/useInAppBrowser"; import React, { useCallback, useRef } from "react"; -import { ScrollView, TouchableOpacity, View } from "react-native"; +import { Image, ScrollView, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; type SettingsScreenProps = NativeStackScreenProps< @@ -24,6 +29,51 @@ type SettingsScreenProps = NativeStackScreenProps< typeof SETTINGS_ROUTES.SETTINGS_SCREEN >; +/** + * The way into Accounts & recovery. The two overlapping circles are fixed + * artwork from the design, shown whatever the number of accounts. + */ +const AccountsRecoveryCard: React.FC<{ onPress: () => void }> = ({ + onPress, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + return ( + + + + {t("settings.accountsRecovery")} + + + {t("settings.manageYourAccounts")} + + + + + + + + + ); +}; + const SettingsScreen: React.FC = ({ navigation }) => { const { logout } = useAuthenticationStore(); const { t } = useAppTranslation(); @@ -163,6 +213,11 @@ const SettingsScreen: React.FC = ({ navigation }) => { className="flex flex-col gap-6 mt-4" style={{ marginBottom: insets.bottom + pxValue(DEFAULT_PADDING) }} > + + navigation.navigate(SETTINGS_ROUTES.ACCOUNTS_RECOVERY_SCREEN) + } + /> diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 0bd86bbfb..0967dfe76 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -65,6 +65,11 @@ export enum AnalyticsEvent { VIEW_ABOUT = "about", VIEW_SECURITY = "security", VIEW_SHOW_RECOVERY_PHRASE = "show_recovery_phrase", + VIEW_UNLOCK_RECOVERY_PHRASE = "unlock_recovery_phrase", + VIEW_UNLOCK_SECRET_KEY = "unlock_secret_key", + VIEW_SHOW_SECRET_KEY = "show_secret_key", + VIEW_ACCOUNTS_RECOVERY = "accounts_recovery", + VIEW_EDIT_ACCOUNT = "edit_account", VIEW_MANAGE_CONNECTED_APPS = "manage_connected_apps", VIEW_MANAGE_TOKENS = "manage_assets", VIEW_ADD_TOKEN = "add_asset", @@ -386,6 +391,21 @@ const SCREEN_CATALOG: Record = { [AnalyticsEvent.VIEW_SHOW_RECOVERY_PHRASE]: { flow: AnalyticsFlow.SECURITY, }, + [AnalyticsEvent.VIEW_UNLOCK_RECOVERY_PHRASE]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_UNLOCK_SECRET_KEY]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_SHOW_SECRET_KEY]: { + flow: AnalyticsFlow.SECURITY, + }, + [AnalyticsEvent.VIEW_ACCOUNTS_RECOVERY]: { + flow: AnalyticsFlow.SETTINGS, + }, + [AnalyticsEvent.VIEW_EDIT_ACCOUNT]: { + flow: AnalyticsFlow.SETTINGS, + }, [AnalyticsEvent.VIEW_IMPORT_SECRET_KEY]: { flow: AnalyticsFlow.SECURITY, }, @@ -576,6 +596,11 @@ const getRoutesWithoutAnalytics = (): Set => { // auto-track a generic view event. excludedRoutes.add("ScanReceiveScreen"); + // The reveal screen names itself from the step it's on (unlock_* first, + // then show_* once the password is accepted). Tracking the route on + // navigate would count a reveal before the password was checked. + excludedRoutes.add("RevealSecretScreen"); + return excludedRoutes; }; diff --git a/src/config/routes.ts b/src/config/routes.ts index 32ea6967b..9b4cc6e4a 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -95,6 +95,9 @@ export const SETTINGS_ROUTES = { YOUR_RECOVERY_PHRASE_SCREEN: "YourRecoveryPhraseScreen", BIOMETRICS_SETTINGS_SCREEN: "BiometricsSettingsScreen", AUTO_LOCK_TIMER_SCREEN: "AutoLockTimerScreen", + ACCOUNTS_RECOVERY_SCREEN: "AccountsRecoveryScreen", + EDIT_ACCOUNT_SCREEN: "EditAccountScreen", + REVEAL_SECRET_SCREEN: "RevealSecretScreen", } as const; export const MANAGE_WALLETS_ROUTES = { @@ -196,6 +199,15 @@ export type ManageTokensStackParamList = { [MANAGE_TOKENS_ROUTES.ADD_TOKEN_SCREEN]: undefined; }; +/** + * What the reveal screen shows: one account's secret key (`accountId` is + * `Account.id`), or a recovery phrase (`sourceId` names the phrase source; + * without one, the wallet's default phrase). + */ +export type RevealSecretParams = + | { kind: "secretKey"; accountId: string } + | { kind: "recoveryPhrase"; sourceId?: string }; + export type SettingsStackParamList = { [SETTINGS_ROUTES.SETTINGS_SCREEN]: undefined; [SETTINGS_ROUTES.PREFERENCES_SCREEN]: undefined; @@ -210,6 +222,12 @@ export type SettingsStackParamList = { [SETTINGS_ROUTES.YOUR_RECOVERY_PHRASE_SCREEN]: undefined; [SETTINGS_ROUTES.BIOMETRICS_SETTINGS_SCREEN]: undefined; [SETTINGS_ROUTES.AUTO_LOCK_TIMER_SCREEN]: undefined; + [SETTINGS_ROUTES.ACCOUNTS_RECOVERY_SCREEN]: undefined; + [SETTINGS_ROUTES.EDIT_ACCOUNT_SCREEN]: { + /** `Account.id`: two accounts can share a public key. */ + accountId: string; + }; + [SETTINGS_ROUTES.REVEAL_SECRET_SCREEN]: RevealSecretParams; }; export type ManageWalletsStackParamList = { diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index c97873153..53e84db00 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -76,6 +76,7 @@ import { getHeldMnemonic, getPhraseSourceId, getSecretKeySourceId, + getSecretSources, pinFirstSource, } from "services/auth/secretSources"; import { getWalletSources } from "services/auth/walletSources"; @@ -4077,6 +4078,123 @@ export const getActiveMnemonicPhrase = async (): Promise => { } }; +const assertAuthenticated = (): void => { + if ( + useAuthenticationStore.getState().authStatus !== AUTH_STATUS.AUTHENTICATED + ) { + throw new Error(t("authStore.error.authenticationExpired")); + } +}; + +/** + * Returns one account's secret key for Edit account's reveal. `accountId` is + * the account's keystore id, so the password is checked against that + * account's own keystore, even though the session is unlocked, and the key + * comes from the same decrypt. Only a keystore the key manager lists is ever + * opened. + * + * @param {Object} input - The input parameters + * @param {string} input.accountId - The account's keystore id (`Account.id`) + * @param {string} input.password - The wallet password + * @returns {Promise} The account's secret key + * @throws {Error} When the wallet is locked, the account isn't held, or the password is wrong + */ +export const revealAccountSecretKey = async ({ + accountId, + password, +}: { + accountId: string; + password: string; +}): Promise => { + assertAuthenticated(); + + const key = await getKeyFromKeyManager(password, accountId); + + // A lock that landed during the decrypt keeps the key from leaving. + assertAuthenticated(); + + if (!key.privateKey) { + throw new Error(t("authStore.error.noKeyPairFound")); + } + + return key.privateKey; +}; + +/** + * Returns a recovery phrase for Edit account's reveal, after checking the + * password against a keystore. Every keystore shares the wallet password. + * + * With a `sourceId`, the phrase that roots that account: it must be a phrase + * source the wallet records. The session's held copy is used when there is + * one; otherwise the phrase is read from one of the source's own keystores + * and kept only if it derives to `sourceId`. Without one, the wallet's + * default phrase, as Settings → Security shows it. + * + * @param {Object} input - The input parameters + * @param {string} input.password - The wallet password + * @param {string} [input.sourceId] - The phrase source to reveal (optional) + * @returns {Promise} The recovery phrase + * @throws {Error} When the wallet is locked, the password is wrong, or the phrase isn't found + */ +export const revealRecoveryPhrase = async ({ + password, + sourceId, +}: { + password: string; + sourceId?: string; +}): Promise => { + assertAuthenticated(); + + const notFound = () => new Error(t("authStore.error.recoveryPhraseNotFound")); + + if (!sourceId) { + await getKeyFromKeyManager(password); + const mnemonic = await getActiveMnemonicPhrase(); + if (!mnemonic) { + throw notFound(); + } + return mnemonic; + } + + const source = (await getSecretSources())[sourceId]; + if (source?.type !== SecretSourceType.PHRASE) { + throw notFound(); + } + + const sourceAccountIds = (await getAllAccounts()) + .filter((account) => account.sourceId === sourceId) + .map(({ id }) => id); + + // Checks the password first, so a wrong one reads as a wrong password + // rather than as a missing phrase. + await getKeyFromKeyManager(password, sourceAccountIds[0]); + + const store = await getTemporaryStore( + useAuthenticationStore.getState().authStatus, + ); + let mnemonic = getHeldMnemonic({ + mnemonics: store?.mnemonics ?? {}, + sourceId, + })?.mnemonic; + + if (!mnemonic) { + const read = await readPhraseFromKeystores(sourceAccountIds, password); + if (read && (await getPhraseSourceId(read)) === sourceId) { + mnemonic = read; + } + } + + // Nothing may await between this check and the return (see + // getActiveMnemonicPhrase). + assertAuthenticated(); + + if (!mnemonic) { + throw notFound(); + } + + return mnemonic; +}; + /** * The secrets the unlocked session holds, for getWalletSources: every account * id in list order, the private keys held for them by account id, and the diff --git a/src/helpers/accountGroups.ts b/src/helpers/accountGroups.ts new file mode 100644 index 000000000..2bc7c57ea --- /dev/null +++ b/src/helpers/accountGroups.ts @@ -0,0 +1,86 @@ +import { Account, SecretSource, SecretSourceType } from "config/types"; +import { TFunction } from "i18next"; + +type Translate = TFunction<"translations">; + +export enum AccountGroupKind { + PHRASE = "phrase", + SECRET_KEY = "secretKey", + OTHER = "other", +} + +export interface AccountGroup { + key: string; + kind: AccountGroupKind; + label: string; + /** The source that roots every account in the group; none for OTHER. */ + sourceId?: string; + accounts: Account[]; +} + +/* + Sources are numbered within their type when they are added ("Seed Phrase 2", + "Secret Key 1") and a number is never reused. The stored label is in + English, so the screens keep that number and translate the rest. +*/ +const getLabelNumber = (label: string) => /(\d+)$/.exec(label)?.[1]; + +export const getSourceLabel = (source: SecretSource, t: Translate): string => { + const number = getLabelNumber(source.label); + + if (!number) { + return source.label; + } + + return source.type === SecretSourceType.PHRASE + ? t("accountsRecoveryScreen.recoveryPhraseLabel", { number }) + : t("accountsRecoveryScreen.secretKeyLabel", { number }); +}; + +/** + * Groups accounts under the source that roots them: phrases first, then + * imported keys, each oldest first. Every account appears exactly once. One + * with no known source (a pre-V2 record sign-in has not attributed yet) lands + * in a trailing group rather than being dropped. Empty groups are left out. + */ +export const groupAccountsBySource = ({ + accounts, + sources, + t, +}: { + accounts: Account[]; + sources: SecretSource[]; + t: Translate; +}): AccountGroup[] => { + const byAge = [...sources].sort((a, b) => a.createdAt - b.createdAt); + const ordered = [ + ...byAge.filter((s) => s.type === SecretSourceType.PHRASE), + ...byAge.filter((s) => s.type === SecretSourceType.SECRET_KEY), + ]; + const known = new Set(ordered.map((s) => s.id)); + + const groups: AccountGroup[] = ordered.map((source) => ({ + key: source.id, + kind: + source.type === SecretSourceType.PHRASE + ? AccountGroupKind.PHRASE + : AccountGroupKind.SECRET_KEY, + label: getSourceLabel(source, t), + sourceId: source.id, + accounts: accounts.filter((account) => account.sourceId === source.id), + })); + + const unattributed = accounts.filter( + (account) => !(account.sourceId && known.has(account.sourceId)), + ); + if (unattributed.length) { + groups.push({ + key: "other", + kind: AccountGroupKind.OTHER, + label: t("accountsRecoveryScreen.otherAccounts"), + accounts: unattributed, + }); + } + + return groups.filter((group) => group.accounts.length); +}; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index b127bb0aa..7765b5678 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -87,7 +87,9 @@ "deleteAccount": "Delete account", "deleteAccountDisclaimer": "Accounts on the Stellar network are immutable and cannot be deleted. However you can remove any account from your Varial App at any time using the 'Delete account' option. Verify your recovery phrase works before you do this.", "deleteAccountConfirmTitle": "Are you sure?", - "deleteAccountConfirmMessage": "Make sure you have your recovery phrase in a safe place. You will not be able to recover your wallet without it." + "deleteAccountConfirmMessage": "Make sure you have your recovery phrase in a safe place. You will not be able to recover your wallet without it.", + "accountsRecovery": "Accounts & recovery", + "manageYourAccounts": "Manage your accounts" }, "preferences": { "usageDataSharing": { @@ -479,6 +481,7 @@ "noKeyPairFound": "No key pair found", "noRecoveryPhrase": "No recovery phrase to derive from", "unknownRecoveryPhrase": "Unknown recovery phrase", + "recoveryPhraseNotFound": "Recovery phrase not found", "failedToSignUp": "Failed to sign up", "failedToSignIn": "Failed to sign in", "failedToImportWallet": "Failed to import wallet", @@ -594,7 +597,6 @@ "alreadyInWallet": "This collectible is already in your wallet", "showHidden": "Show hidden" }, - "collectibles": { "errors": { "collectionNotFound": "Collection not found for the given address.", @@ -1389,5 +1391,42 @@ "title": "Cannot run on this device", "description": "This device is rooted or jailbroken, which prevents the app from protecting your wallet keys.", "disclaimer": "The app is disabled on compromised systems. Install and use it on a device with standard security intact." + }, + "accountsRecoveryScreen": { + "title": "Accounts & recovery", + "recoveryPhraseLabel": "Recovery phrase {{number}}", + "secretKeyLabel": "Secret key {{number}}", + "otherAccounts": "Other accounts", + "editAccount": "Edit {{name}}" + }, + "editAccountScreen": { + "title": "Edit account", + "accountName": "Account name", + "secretKey": "Secret key", + "unlockToReveal": "Unlock to reveal", + "viewOnExplorer": "View on block explorer" + }, + "revealSecretScreen": { + "consentBannerBody": "Anyone with it can take control of your wallet.", + "consentSubtitle": "Keep it secure. You'll need it to restore your wallet.", + "tapToCopy": "Tap to copy", + "recoveryPhrase": { + "title": "Show recovery phrase", + "revealedTitle": "Your recovery phrase", + "bannerTitle": "Never share your recovery phrase", + "revealedBannerBody": "Anyone with it can take control of your wallet.", + "heading": "Your recovery phrase controls this wallet", + "acknowledgement": "I understand that sharing my recovery phrase could result in permanent loss of funds.", + "action": "Reveal recovery phrase" + }, + "secretKey": { + "title": "Show secret key", + "revealedTitle": "Your secret key", + "bannerTitle": "Never share your secret key", + "revealedBannerBody": "Anyone with this key can access your account and funds. Keep it private and store it securely.", + "heading": "Your secret key controls this wallet", + "acknowledgement": "I understand that sharing my secret key could result in permanent loss of funds.", + "action": "Reveal secret key" + } } } diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 2789a4796..3f8db3f17 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -87,7 +87,9 @@ "deleteAccount": "Excluir conta", "deleteAccountDisclaimer": "As contas na rede Stellar são imutáveis e não podem ser excluídas. No entanto, você pode remover qualquer conta do seu aplicativo Varial a qualquer momento usando a opção 'Excluir conta'. Verifique se sua frase de recuperação funciona antes de fazer isso.", "deleteAccountConfirmTitle": "Tem certeza?", - "deleteAccountConfirmMessage": "Certifique-se de que você tem sua frase de recuperação em um local seguro. Você não conseguirá recuperar sua carteira sem ela." + "deleteAccountConfirmMessage": "Certifique-se de que você tem sua frase de recuperação em um local seguro. Você não conseguirá recuperar sua carteira sem ela.", + "accountsRecovery": "Contas e recuperação", + "manageYourAccounts": "Gerencie suas contas" }, "preferences": { "usageDataSharing": { @@ -445,6 +447,7 @@ "noKeyPairFound": "Nenhum par de chaves encontrado", "noRecoveryPhrase": "Nenhuma frase de recuperação para derivar", "unknownRecoveryPhrase": "Frase de recuperação desconhecida", + "recoveryPhraseNotFound": "Frase de recuperação não encontrada", "failedToSignUp": "Falha ao criar conta", "failedToSignIn": "Falha ao entrar", "failedToImportWallet": "Falha ao importar carteira", @@ -560,7 +563,6 @@ "alreadyInWallet": "Este colecionável já está na sua carteira", "showHidden": "Mostrar ocultos" }, - "collectibles": { "errors": { "collectionNotFound": "Coleção não encontrada para o endereço fornecido.", @@ -1389,5 +1391,42 @@ "title": "Não é possível executar neste dispositivo", "description": "Este dispositivo possui root ou jailbreak, o que impede o aplicativo de proteger as chaves da sua carteira.", "disclaimer": "O aplicativo está desabilitado em sistemas comprometidos. Instale e use-o em um dispositivo com segurança padrão intacta." + }, + "accountsRecoveryScreen": { + "title": "Contas e recuperação", + "recoveryPhraseLabel": "Frase de recuperação {{number}}", + "secretKeyLabel": "Chave secreta {{number}}", + "otherAccounts": "Outras contas", + "editAccount": "Editar {{name}}" + }, + "editAccountScreen": { + "title": "Editar conta", + "accountName": "Nome da conta", + "secretKey": "Chave secreta", + "unlockToReveal": "Desbloqueie para revelar", + "viewOnExplorer": "Ver no explorador de blocos" + }, + "revealSecretScreen": { + "consentBannerBody": "Qualquer pessoa que a tenha pode assumir o controle da sua carteira.", + "consentSubtitle": "Guarde-a em segurança. Você precisará dela para restaurar sua carteira.", + "tapToCopy": "Toque para copiar", + "recoveryPhrase": { + "title": "Mostrar frase de recuperação", + "revealedTitle": "Sua frase de recuperação", + "bannerTitle": "Nunca compartilhe sua frase de recuperação", + "revealedBannerBody": "Qualquer pessoa que a tenha pode assumir o controle da sua carteira.", + "heading": "Sua frase de recuperação controla esta carteira", + "acknowledgement": "Entendo que compartilhar minha frase de recuperação pode resultar na perda permanente de fundos.", + "action": "Revelar frase de recuperação" + }, + "secretKey": { + "title": "Mostrar chave secreta", + "revealedTitle": "Sua chave secreta", + "bannerTitle": "Nunca compartilhe sua chave secreta", + "revealedBannerBody": "Qualquer pessoa com esta chave pode acessar sua conta e seus fundos. Mantenha-a privada e guarde-a em segurança.", + "heading": "Sua chave secreta controla esta carteira", + "acknowledgement": "Entendo que compartilhar minha chave secreta pode resultar na perda permanente de fundos.", + "action": "Revelar chave secreta" + } } } diff --git a/src/navigators/SettingsNavigator.tsx b/src/navigators/SettingsNavigator.tsx index 038bf3ae4..6044bbf6d 100644 --- a/src/navigators/SettingsNavigator.tsx +++ b/src/navigators/SettingsNavigator.tsx @@ -5,7 +5,10 @@ import ChangeNetworkScreen from "components/screens/ChangeNetworkScreen"; import NetworkSettingsScreen from "components/screens/ChangeNetworkScreen/NetworkSettingsScreen"; import SettingsScreen from "components/screens/SettingsScreen"; import AboutScreen from "components/screens/SettingsScreen/AboutScreen"; +import AccountsRecoveryScreen from "components/screens/SettingsScreen/AccountsRecoveryScreen"; +import EditAccountScreen from "components/screens/SettingsScreen/EditAccountScreen"; import PreferencesScreen from "components/screens/SettingsScreen/PreferencesScreen"; +import RevealSecretScreen from "components/screens/SettingsScreen/RevealSecretScreen"; import SecurityScreen from "components/screens/SettingsScreen/SecurityScreen"; import AutoLockTimerScreen from "components/screens/SettingsScreen/SecurityScreen/AutoLockTimerScreen"; import BiometricsSettingsScreen from "components/screens/SettingsScreen/SecurityScreen/BiometricsSettingsScreen"; @@ -48,6 +51,24 @@ export const SettingsStackNavigator = () => { component={SettingsScreen} options={getScreenBottomNavigateOptions(t("settings.title"))} /> + + + Date: Tue, 10 Nov 2026 11:21:37 -0700 Subject: [PATCH 02/14] fix(settings): group accounts on first render, rename in a bottom sheet Accounts & recovery waits for the secret sources before listing accounts, so rows no longer appear ungrouped and then regroup. Rename on Edit account opens the floating bottom sheet; Home keeps the centered modal. --- .../AccountsRecoveryScreen.test.tsx | 26 ++++ .../SettingsScreen/EditAccountScreen.test.tsx | 29 ++++- .../screens/HomeScreen/RenameAccountForm.tsx | 104 ++++++++++++++++ .../screens/HomeScreen/RenameAccountModal.tsx | 111 ++++-------------- .../AccountsRecoveryScreen.tsx | 11 +- .../EditAccountScreen/EditAccountScreen.tsx | 32 +++-- 6 files changed, 206 insertions(+), 107 deletions(-) create mode 100644 src/components/screens/HomeScreen/RenameAccountForm.tsx diff --git a/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx b/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx index b43e7530d..a4edf19f9 100644 --- a/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx +++ b/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx @@ -4,6 +4,7 @@ import { SETTINGS_ROUTES } from "config/routes"; import { Account } from "config/types"; import { renderWithProviders } from "helpers/testUtils"; import React from "react"; +import { getSecretSources } from "services/auth/secretSources"; jest.mock("@react-navigation/native", () => { const { useEffect } = jest.requireActual("react"); @@ -94,6 +95,31 @@ describe("AccountsRecoveryScreen", () => { expect(within(keyGroup).getByText("Imported")).toBeTruthy(); }); + it("lists nothing until the sources are read, so rows never regroup", async () => { + let resolveSources: (value: object) => void = () => {}; + (getSecretSources as jest.Mock).mockReturnValueOnce( + new Promise((resolve) => { + resolveSources = resolve; + }), + ); + const screen = renderScreen(); + + expect(screen.queryAllByTestId("accounts-recovery-row")).toHaveLength(0); + + resolveSources({ + "phrase-1": { + id: "phrase-1", + type: "phrase", + label: "Seed Phrase 1", + createdAt: 1, + }, + }); + + await waitFor(() => + expect(screen.getByText("Recovery phrase 1")).toBeTruthy(), + ); + }); + it("shows totals and marks the active account", async () => { const screen = renderScreen(); diff --git a/__tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx b/__tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx index 354746185..8a4f3e10f 100644 --- a/__tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx +++ b/__tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx @@ -15,10 +15,29 @@ jest.mock("hooks/useInAppBrowser", () => ({ useInAppBrowser: () => ({ open: mockOpenInAppBrowser }), })); -jest.mock("components/screens/HomeScreen/RenameAccountModal", () => { +const mockPresent = jest.fn(); +jest.mock("components/BottomSheet", () => { + const { useImperativeHandle } = jest.requireActual("react"); + return ({ + modalRef, + customContent, + }: { + modalRef: React.Ref; + customContent: React.ReactNode; + }) => { + useImperativeHandle(modalRef, () => ({ + present: mockPresent, + dismiss: jest.fn(), + })); + return customContent; + }; +}); + +jest.mock("components/screens/HomeScreen/RenameAccountForm", () => { const { Text } = jest.requireActual("react-native"); - return ({ modalVisible }: { modalVisible: boolean }) => - modalVisible ? rename : null; + return ({ isBottomSheetInput }: { isBottomSheetInput?: boolean }) => ( + {String(isBottomSheetInput)} + ); }); jest.mock("services/auth/secretSources", () => ({ @@ -114,7 +133,9 @@ describe("EditAccountScreen", () => { expect(screen.getByText("Account 1")).toBeTruthy(); fireEvent.press(screen.getByTestId("edit-account-name")); - expect(screen.getByTestId("rename-modal")).toBeTruthy(); + expect(mockPresent).toHaveBeenCalledTimes(1); + // The form sits in a bottom sheet, so its field uses the sheet's input. + expect(screen.getByTestId("rename-form").props.children).toBe("true"); }); it("links the account on stellar.expert for the current network", () => { diff --git a/src/components/screens/HomeScreen/RenameAccountForm.tsx b/src/components/screens/HomeScreen/RenameAccountForm.tsx new file mode 100644 index 000000000..8f2e6dd72 --- /dev/null +++ b/src/components/screens/HomeScreen/RenameAccountForm.tsx @@ -0,0 +1,104 @@ +import { BottomSheetHeader } from "components/BottomSheetHeader"; +import { Avatar } from "components/sds/Avatar"; +import { Button } from "components/sds/Button"; +import { Input } from "components/sds/Input"; +import { + ACCOUNT_NAME_MAX_LENGTH, + ACCOUNT_NAME_MIN_LENGTH, +} from "config/constants"; +import { Account } from "config/types"; +import useAppTranslation from "hooks/useAppTranslation"; +import React, { useEffect, useMemo, useState } from "react"; +import { View } from "react-native"; + +export interface RenameAccountFormProps { + /** Account being renamed; null until a rename is requested. */ + account: Account | null; + isRenamingAccount: boolean; + onCancel: () => void; + onSave: (newAccountName: string) => Promise; + /** Set inside a bottom sheet so the field works with its keyboard handling. */ + isBottomSheetInput?: boolean; +} + +/** + * The rename form: avatar, a single autofocused name input and Cancel / Save + * name actions. Shared by the centered modal on Home and the bottom sheet on + * Edit account. + */ +const RenameAccountForm: React.FC = ({ + account, + isRenamingAccount, + onCancel, + onSave, + isBottomSheetInput = false, +}) => { + const { t } = useAppTranslation(); + const [accountName, setAccountName] = useState(account?.name ?? ""); + + useEffect(() => { + setAccountName(account?.name ?? ""); + }, [account]); + + const isAccountNameValid = useMemo( + () => + accountName.trim().length >= ACCOUNT_NAME_MIN_LENGTH && + accountName.trim().length <= ACCOUNT_NAME_MAX_LENGTH, + [accountName], + ); + + return ( + + + + + + + + + + + + + + + + + + ); +}; + +export default RenameAccountForm; diff --git a/src/components/screens/HomeScreen/RenameAccountModal.tsx b/src/components/screens/HomeScreen/RenameAccountModal.tsx index f848c4b6a..e5451963f 100644 --- a/src/components/screens/HomeScreen/RenameAccountModal.tsx +++ b/src/components/screens/HomeScreen/RenameAccountModal.tsx @@ -1,18 +1,9 @@ -import { BottomSheetHeader } from "components/BottomSheetHeader"; import Modal from "components/Modal"; -import { Avatar } from "components/sds/Avatar"; -import { Button } from "components/sds/Button"; -import { Input } from "components/sds/Input"; -import { - ACCOUNT_NAME_MAX_LENGTH, - ACCOUNT_NAME_MIN_LENGTH, - DEFAULT_PADDING, -} from "config/constants"; +import RenameAccountForm from "components/screens/HomeScreen/RenameAccountForm"; +import { DEFAULT_PADDING } from "config/constants"; import { Account } from "config/types"; import { pxValue } from "helpers/dimensions"; -import useAppTranslation from "hooks/useAppTranslation"; -import React, { useEffect, useMemo, useState } from "react"; -import { View } from "react-native"; +import React from "react"; interface RenameAccountModalProps { modalVisible: boolean; @@ -24,8 +15,7 @@ interface RenameAccountModalProps { } /** - * Modal for renaming a wallet: avatar, a single autofocused name input and - * Cancel / Save name actions. Uses the shared Modal's `position="keyboard"` + * Modal for renaming a wallet. Uses the shared Modal's `position="keyboard"` * mode so the card slides with the keyboard instead of jumping. */ const RenameAccountModal: React.FC = ({ @@ -34,81 +24,22 @@ const RenameAccountModal: React.FC = ({ handleRenameAccount, account, isRenamingAccount, -}) => { - const { t } = useAppTranslation(); - const [accountName, setAccountName] = useState(account?.name ?? ""); - - useEffect(() => { - setAccountName(account?.name ?? ""); - }, [account]); - - const isAccountNameValid = useMemo( - () => - accountName.trim().length >= ACCOUNT_NAME_MIN_LENGTH && - accountName.trim().length <= ACCOUNT_NAME_MAX_LENGTH, - [accountName], - ); - - return ( - setModalVisible(false)} - closeOnOverlayPress={false} - position="keyboard" - contentClassName="bg-background-primary rounded-[32px] w-full" - contentStyle={{ padding: pxValue(DEFAULT_PADDING) }} - > - - - - setModalVisible(false)} - // Closing mid-rename would drop the result, so the X is - // disabled until the save settles, like the Cancel button. - closeDisabled={isRenamingAccount} - closeTestID="rename-account-close-button" - /> - - - - - - - - - - - - - - - ); -}; +}) => ( + setModalVisible(false)} + closeOnOverlayPress={false} + position="keyboard" + contentClassName="bg-background-primary rounded-[32px] w-full" + contentStyle={{ padding: pxValue(DEFAULT_PADDING) }} + > + setModalVisible(false)} + onSave={handleRenameAccount} + /> + +); export default RenameAccountModal; diff --git a/src/components/screens/SettingsScreen/AccountsRecoveryScreen/AccountsRecoveryScreen.tsx b/src/components/screens/SettingsScreen/AccountsRecoveryScreen/AccountsRecoveryScreen.tsx index 97c8a04c9..9ce9a3f88 100644 --- a/src/components/screens/SettingsScreen/AccountsRecoveryScreen/AccountsRecoveryScreen.tsx +++ b/src/components/screens/SettingsScreen/AccountsRecoveryScreen/AccountsRecoveryScreen.tsx @@ -139,7 +139,9 @@ const AccountsRecoveryScreen: React.FC = ({ const fetchAccountsFiatTotals = useAccountsFiatTotalsStore( (state) => state.fetchAccountsFiatTotals, ); - const [sources, setSources] = useState([]); + // Null until the first read, so the list isn't drawn ungrouped and then + // regrouped once the sources arrive. + const [sources, setSources] = useState(null); // Refetched on focus so a rename on Edit account shows on return. useFocusEffect( @@ -153,6 +155,8 @@ const AccountsRecoveryScreen: React.FC = ({ "Failed to read secret sources", error, ); + // Still list the accounts, under the trailing group. + setSources((current) => current ?? []); }); }, [getAllAccounts]), ); @@ -177,7 +181,10 @@ const AccountsRecoveryScreen: React.FC = ({ ); const groups = useMemo( - () => groupAccountsBySource({ accounts: allAccounts, sources, t }), + () => + sources + ? groupAccountsBySource({ accounts: allAccounts, sources, t }) + : [], [allAccounts, sources, t], ); diff --git a/src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx b/src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx index c4bf48fb4..0e5b47c13 100644 --- a/src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx +++ b/src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx @@ -1,7 +1,9 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import BottomSheet from "components/BottomSheet"; import { List, ListItemProps } from "components/List"; import { BaseLayout } from "components/layout/BaseLayout"; -import RenameAccountModal from "components/screens/HomeScreen/RenameAccountModal"; +import RenameAccountForm from "components/screens/HomeScreen/RenameAccountForm"; import Avatar from "components/sds/Avatar"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; @@ -20,7 +22,7 @@ import { getStellarExpertUrl } from "helpers/stellarExpert"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import { useInAppBrowser } from "hooks/useInAppBrowser"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { ScrollView, View } from "react-native"; import { analytics } from "services/analytics"; import { getSecretSources } from "services/auth/secretSources"; @@ -48,7 +50,7 @@ const EditAccountScreen: React.FC = ({ const { allAccounts, network, renameAccount, isRenamingAccount } = useAuthenticationStore(); const [phraseSource, setPhraseSource] = useState(null); - const [isRenameVisible, setIsRenameVisible] = useState(false); + const renameSheetRef = useRef(null); const account = allAccounts.find(({ id }) => id === accountId) ?? null; const sourceId = account?.sourceId; @@ -88,7 +90,7 @@ const EditAccountScreen: React.FC = ({ analytics.trackViewPublicKeyAccountRenamed(); await renameAccount({ accountName, publicKey: account.publicKey }); - setIsRenameVisible(false); + renameSheetRef.current?.dismiss(); }, [account, renameAccount], ); @@ -111,7 +113,7 @@ const EditAccountScreen: React.FC = ({ { title: t("editAccountScreen.accountName"), titleColor: themeColors.text.primary, - onPress: () => setIsRenameVisible(true), + onPress: () => renameSheetRef.current?.present(), trailingContent: ( @@ -190,12 +192,20 @@ const EditAccountScreen: React.FC = ({ - renameSheetRef.current?.dismiss()} + onSave={handleRename} + isBottomSheetInput + /> + } /> ); From d419f94bdaf2efc45113fa667fbdc062645ad72a Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Tue, 10 Nov 2026 11:50:57 -0700 Subject: [PATCH 03/14] feat(accounts): group the account switcher by source Ports stellar/freighter#3059. The switcher groups wallets under the recovery phrase or secret key behind them, with collapsible headers that stay collapsed until the app restarts. A wallet with one source stays a flat list. The header keeps only the address and a copy button, since rename and the explorer link are on Edit account and QR is on the scan screen. Rows drop the "Imported" tag, and the unused rename modal is removed. --- .../HomeScreen/AccountItemRow.test.tsx | 9 +- .../ManageAccountBottomSheet.test.tsx | 116 +++++++-- __tests__/helpers/accountGroups.test.ts | 15 ++ .../screens/HomeScreen/AccountItemRow.tsx | 24 +- .../HomeScreen/ManageAccountBottomSheet.tsx | 226 +++++++++--------- .../screens/HomeScreen/ManageAccounts.tsx | 172 ++++--------- .../screens/HomeScreen/RenameAccountForm.tsx | 3 +- .../screens/HomeScreen/RenameAccountModal.tsx | 45 ---- src/helpers/accountGroups.ts | 1 + src/i18n/locales/en/translations.json | 7 +- src/i18n/locales/pt/translations.json | 7 +- 11 files changed, 297 insertions(+), 328 deletions(-) delete mode 100644 src/components/screens/HomeScreen/RenameAccountModal.tsx diff --git a/__tests__/components/screens/HomeScreen/AccountItemRow.test.tsx b/__tests__/components/screens/HomeScreen/AccountItemRow.test.tsx index 2541df0d0..bf5e08c9e 100644 --- a/__tests__/components/screens/HomeScreen/AccountItemRow.test.tsx +++ b/__tests__/components/screens/HomeScreen/AccountItemRow.test.tsx @@ -174,15 +174,16 @@ describe("AccountItemRow", () => { expect(getByTestId("account-row-0-selected-badge")).toBeTruthy(); }); - it("marks imported accounts", () => { - const { getByText } = renderWithProviders( + // The switcher's group header says where an account comes from. + it("doesn't tag imported accounts", () => { + const { queryByText } = renderWithProviders( , ); - expect(getByText(/Imported/)).toBeTruthy(); + expect(queryByText(/Imported/)).toBeNull(); }); it("announces the row's visible content, skipping the total while it loads", () => { @@ -195,7 +196,7 @@ describe("AccountItemRow", () => { ); expect(getByTestId("account-row-0-select").props.accessibilityLabel).toBe( - "my account 1, Imported, $1,149.23", + "my account 1, $1,149.23", ); // While the total is loading (spinner), no placeholder $0.00 is spoken. diff --git a/__tests__/components/screens/HomeScreen/ManageAccountBottomSheet.test.tsx b/__tests__/components/screens/HomeScreen/ManageAccountBottomSheet.test.tsx index 8faab2936..52a627f09 100644 --- a/__tests__/components/screens/HomeScreen/ManageAccountBottomSheet.test.tsx +++ b/__tests__/components/screens/HomeScreen/ManageAccountBottomSheet.test.tsx @@ -1,8 +1,8 @@ -import { fireEvent } from "@testing-library/react-native"; +import { fireEvent, within } from "@testing-library/react-native"; import ManageAccountBottomSheet, { ManageAccountSheetHeader, } from "components/screens/HomeScreen/ManageAccountBottomSheet"; -import { Account } from "config/types"; +import { Account, SecretSource, SecretSourceType } from "config/types"; import { ActiveAccount } from "ducks/auth"; import { renderWithProviders } from "helpers/testUtils"; import React from "react"; @@ -10,11 +10,42 @@ import React from "react"; describe("ManageAccountBottomSheet", () => { const PK_1 = "GDNF5WJ2BEPABVBXCF4C7KZKM3XYXP27VUE3SCGPZA3VXWWZ7OFA3VPM"; const PK_2 = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + const PK_3 = "GBJ5JHXZJLI4QGZQ3OHQ6CN7TQHZMTPVKTPY6MZ2BQOXLK3QMSFZPRFY"; + + const phrase: SecretSource = { + id: "phrase-1", + type: SecretSourceType.PHRASE, + label: "Seed Phrase 1", + createdAt: 1, + }; + const key: SecretSource = { + id: "key-1", + type: SecretSourceType.SECRET_KEY, + label: "Secret Key 1", + createdAt: 2, + }; const mockAccounts: Account[] = [ - { id: "account-1", name: "my account 1", publicKey: PK_1 }, - { id: "account-2", name: "my account 2", publicKey: PK_2 }, + { + id: "account-1", + name: "my account 1", + publicKey: PK_1, + sourceId: phrase.id, + }, + { + id: "account-2", + name: "my account 2", + publicKey: PK_2, + sourceId: phrase.id, + }, ]; + const importedAccount: Account = { + id: "account-3", + name: "imported", + publicKey: PK_3, + sourceId: key.id, + importedFromSecretKey: true, + }; const mockActiveAccount: ActiveAccount = { publicKey: PK_1, @@ -25,11 +56,9 @@ describe("ManageAccountBottomSheet", () => { }; const defaultProps = { - onPressMyQRCode: jest.fn(), onPressCopyAddress: jest.fn(), - onPressViewOnExplorer: jest.fn(), - onPressRenameAccount: jest.fn(), accounts: mockAccounts, + sources: [phrase], activeAccount: mockActiveAccount, handleSelectAccount: jest.fn().mockResolvedValue(undefined), isAccountSwitching: false, @@ -41,6 +70,12 @@ describe("ManageAccountBottomSheet", () => { isLoadingFiatTotals: false, }; + const groupedProps = { + ...defaultProps, + accounts: [...mockAccounts, importedAccount], + sources: [key, phrase], + }; + beforeEach(() => { jest.clearAllMocks(); }); @@ -66,19 +101,19 @@ describe("ManageAccountBottomSheet", () => { expect(getByText("$872.48")).toBeTruthy(); }); - it.each([ - ["manage-accounts-qr-button", "onPressMyQRCode"], - ["manage-accounts-copy-button", "onPressCopyAddress"], - ["manage-accounts-explorer-button", "onPressViewOnExplorer"], - ["manage-accounts-rename-button", "onPressRenameAccount"], - ] as const)("pressing %s calls %s", (testID, handlerName) => { - const { getByTestId } = renderWithProviders( + it("copies the active address from the header, its only action", () => { + const { getByTestId, queryByTestId } = renderWithProviders( , ); - fireEvent.press(getByTestId(testID)); + fireEvent.press(getByTestId("manage-accounts-copy-button")); - expect(defaultProps[handlerName]).toHaveBeenCalledTimes(1); + expect(defaultProps.onPressCopyAddress).toHaveBeenCalledTimes(1); + // QR is reached from Home's scan button; rename and the explorer link + // live on Edit account. + expect(queryByTestId("manage-accounts-qr-button")).toBeNull(); + expect(queryByTestId("manage-accounts-explorer-button")).toBeNull(); + expect(queryByTestId("manage-accounts-rename-button")).toBeNull(); }); it("selects an account from the list", () => { @@ -113,12 +148,57 @@ describe("ManageAccountBottomSheet", () => { expect(getByTestId("manage-accounts-active-avatar")).toBeTruthy(); }); - it("renders the rows wrapper once there are accounts", () => { - const { getByTestId } = renderWithProviders( + it("holds the list back until the sources are read", () => { + const { queryByTestId } = renderWithProviders( + , + ); + + expect(queryByTestId("manage-accounts-list")).toBeNull(); + }); + + it("lists a single source flat, with no group header", () => { + const { getByTestId, queryAllByTestId } = renderWithProviders( , ); expect(getByTestId("manage-accounts-list")).toBeTruthy(); + expect(queryAllByTestId("manage-accounts-group")).toHaveLength(0); + }); + + it("groups wallets under their phrase, then their key", () => { + const { getAllByTestId } = renderWithProviders( + , + ); + + const [phraseGroup, keyGroup] = getAllByTestId("manage-accounts-group"); + expect(within(phraseGroup).getByText("Recovery phrase 1")).toBeTruthy(); + expect(within(phraseGroup).getByText("my account 2")).toBeTruthy(); + expect(within(keyGroup).getByText("Secret key 1")).toBeTruthy(); + expect(within(keyGroup).getByText("imported")).toBeTruthy(); + }); + + it("collapses a group, and keeps it collapsed when the sheet reopens", () => { + const first = renderWithProviders( + , + ); + const [, keyToggle] = first.getAllByTestId("manage-accounts-group-toggle"); + + fireEvent.press(keyToggle); + + expect(first.queryByText("imported")).toBeNull(); + expect(keyToggle.props.accessibilityState).toEqual({ expanded: false }); + first.unmount(); + + // The sheet's content unmounts on dismiss; the collapse outlives it. + const reopened = renderWithProviders( + , + ); + expect(reopened.queryByText("imported")).toBeNull(); + expect(reopened.getByText("my account 2")).toBeTruthy(); + + // Expanding again restores the rows, and the state for later tests. + fireEvent.press(reopened.getAllByTestId("manage-accounts-group-toggle")[1]); + expect(reopened.getByText("imported")).toBeTruthy(); }); }); diff --git a/__tests__/helpers/accountGroups.test.ts b/__tests__/helpers/accountGroups.test.ts index 6fbd09595..cb00f8d08 100644 --- a/__tests__/helpers/accountGroups.test.ts +++ b/__tests__/helpers/accountGroups.test.ts @@ -111,6 +111,21 @@ describe("groupAccountsBySource", () => { expect(other.accounts.map(({ id }) => id)).toEqual(["legacy", "orphan"]); }); + // The switcher remembers collapsed groups by key. + it("keeps each group's key stable when accounts are reordered", () => { + const keyedSources = [ + phrase("p1", "Seed Phrase 1", 1), + secretKey("k1", "Secret Key 1", 2), + ]; + const accounts = [account("a1", "p1"), account("a2", "k1"), account("a3")]; + const keys = (list: Account[]) => + groupAccountsBySource({ accounts: list, sources: keyedSources, t }).map( + (g) => g.key, + ); + + expect(keys([...accounts].reverse())).toEqual(keys(accounts)); + }); + it("leaves out sources with no accounts", () => { const groups = groupAccountsBySource({ accounts: [account("c", "key-1")], diff --git a/src/components/screens/HomeScreen/AccountItemRow.tsx b/src/components/screens/HomeScreen/AccountItemRow.tsx index 934e4d609..b83b4617a 100644 --- a/src/components/screens/HomeScreen/AccountItemRow.tsx +++ b/src/components/screens/HomeScreen/AccountItemRow.tsx @@ -6,7 +6,6 @@ import { Account } from "config/types"; import { AccountFiatTotal } from "ducks/accountsFiatTotals"; import { getTotalUsdLabel } from "helpers/balances"; import { truncateAddress } from "helpers/stellar"; -import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import React, { useCallback } from "react"; import { @@ -35,7 +34,7 @@ interface AccountItemRowProps { /** * One wallet row in the manage-accounts list: avatar with a selected badge, - * name, truncated address (plus an "Imported" tag when applicable) and the + * name, truncated address and the * account's USD total — a spinner while the total is loading, and a * switching overlay while this account is being switched to. */ @@ -50,7 +49,6 @@ const AccountItemRow: React.FC = ({ testID, }) => { const { themeColors } = useColors(); - const { t } = useAppTranslation(); const truncatedPublicKey = truncateAddress(account.publicKey); const showSelectedBadge = @@ -73,13 +71,12 @@ const AccountItemRow: React.FC = ({ hasPrices: false, }); - // Announce what the row shows — name, imported marker and USD total (only + // Announce what the row shows: name and USD total (only // once one is actually displayed, never a placeholder $0.00 mid-load). // The truncated address is deliberately left out: spoken character soup // identifies the account worse than its name does. const rowAccessibilityLabel = [ account.name, - account.importedFromSecretKey ? t("home.account.imported") : null, isTotalLoading ? null : fiatTotalLabel, ] .filter(Boolean) @@ -144,18 +141,11 @@ const AccountItemRow: React.FC = ({ {account.name} - - - - {truncatedPublicKey} - - - {account.importedFromSecretKey && ( - - {` • ${t("home.account.imported")}`} - - )} - + {/* No "Imported" tag: the group header says where the account + comes from. */} + + {truncatedPublicKey} + {renderFiatTotal()} diff --git a/src/components/screens/HomeScreen/ManageAccountBottomSheet.tsx b/src/components/screens/HomeScreen/ManageAccountBottomSheet.tsx index 7267dc1fe..5805d3a1c 100644 --- a/src/components/screens/HomeScreen/ManageAccountBottomSheet.tsx +++ b/src/components/screens/HomeScreen/ManageAccountBottomSheet.tsx @@ -3,21 +3,22 @@ import AccountItemRow from "components/screens/HomeScreen/AccountItemRow"; import Avatar from "components/sds/Avatar"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; -import { Account } from "config/types"; +import { Account, SecretSource } from "config/types"; import { AccountFiatTotal } from "ducks/accountsFiatTotals"; import { ActiveAccount } from "ducks/auth"; +import { groupAccountsBySource } from "helpers/accountGroups"; import { truncateAddress } from "helpers/stellar"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; -import React from "react"; +import React, { useMemo, useState } from "react"; import { TouchableOpacity, View } from "react-native"; interface ManageAccountBottomSheetProps { - onPressMyQRCode: () => void; onPressCopyAddress: () => void; - onPressViewOnExplorer: () => void; - onPressRenameAccount: () => void; accounts: Account[]; + /** The wallet's secret sources; null until read, which holds the list back + * so it doesn't draw ungrouped and then regroup. */ + sources: SecretSource[] | null; activeAccount: ActiveAccount | null; handleSelectAccount: (publicKey: string) => Promise; isAccountSwitching: boolean; @@ -32,42 +33,14 @@ interface ManageAccountSheetHeaderProps { } const HEADER_ICON_SIZE = 24; -const ACTION_ICON_SIZE = 20; +const COPY_ICON_SIZE = 14; -interface CircleButtonProps { - children: React.ReactNode; - onPress: () => void; - /** Full size classes, e.g. "w-[40px] h-[40px]" (must be static for NativeWind) */ - sizeClassName: string; - accessibilityLabel?: string; - disabled?: boolean; - testID?: string; -} - -// Memoized: rendered via the quick-actions map below. -const CircleButton: React.FC = React.memo( - ({ - children, - onPress, - sizeClassName, - accessibilityLabel, - disabled, - testID, - }) => ( - - {children} - - ), -); - -CircleButton.displayName = "CircleButton"; +/* + Groups the user collapsed, kept for the rest of the app session so the + sheet reopens the way they left it. Module scope, like the extension, so it + outlives the sheet's content, which unmounts on every dismiss. +*/ +const collapsedGroupKeys = new Set(); /** * Settings/close row pinned above the manage-accounts sheet's scrollable @@ -106,19 +79,18 @@ export const ManageAccountSheetHeader: React.FC< }; /** - * Scrollable body of the manage-accounts sheet: hero with the active - * wallet's avatar/name/address, its four quick-action buttons, and the - * all-wallets list with per-account USD totals. Presentation-only — every - * action and the switching state are owned by the ManageAccounts wrapper. + * Scrollable body of the manage-accounts sheet: the active wallet's avatar, + * name and address (with an inline copy button), then the wallets grouped by + * the secret that controls them, each with its USD total. Rename and the + * explorer link live on Edit account. Presentation-only: every action and the + * switching state are owned by the ManageAccounts wrapper. */ export const ManageAccountBottomSheet: React.FC< ManageAccountBottomSheetProps > = ({ - onPressMyQRCode, onPressCopyAddress, - onPressViewOnExplorer, - onPressRenameAccount, accounts, + sources, activeAccount, handleSelectAccount, isAccountSwitching, @@ -128,35 +100,84 @@ export const ManageAccountBottomSheet: React.FC< }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); + const [collapsed, setCollapsed] = useState>( + () => new Set(collapsedGroupKeys), + ); + + const groups = useMemo( + () => (sources ? groupAccountsBySource({ accounts, sources, t }) : []), + [accounts, sources, t], + ); + + const toggleGroup = (key: string) => { + if (collapsedGroupKeys.has(key)) { + collapsedGroupKeys.delete(key); + } else { + collapsedGroupKeys.add(key); + } + setCollapsed(new Set(collapsedGroupKeys)); + }; + + // Row test ids follow the wallet's position in the full list, so they don't + // shift when a group above collapses. + const renderRows = (rows: Account[]) => + rows.map((account) => ( + + )); - const iconColor = themeColors.foreground.primary; + const renderList = () => { + // One source has nothing to tell apart, and collapsing it would only hide + // the whole list, so a single group renders flat. + if (groups.length <= 1) { + return renderRows(accounts); + } - const activeAccountActions = [ - { - icon: , - onPress: onPressMyQRCode, - accessibilityLabel: t("home.actions.myQRCode"), - testID: "manage-accounts-qr-button", - }, - { - icon: , - onPress: onPressCopyAddress, - accessibilityLabel: t("home.manageAccount.copyAddress"), - testID: "manage-accounts-copy-button", - }, - { - icon: , - onPress: onPressViewOnExplorer, - accessibilityLabel: t("home.manageAccount.viewOnExplorer"), - testID: "manage-accounts-explorer-button", - }, - { - icon: , - onPress: onPressRenameAccount, - accessibilityLabel: t("home.manageAccount.renameWallet"), - testID: "manage-accounts-rename-button", - }, - ]; + return groups.map((group, index) => { + const isExpanded = !collapsed.has(group.key); + + return ( + + {index > 0 && ( + + )} + + toggleGroup(group.key)} + accessibilityRole="button" + accessibilityState={{ expanded: isExpanded }} + testID="manage-accounts-group-toggle" + > + + {group.label} + + + + + + {isExpanded && renderRows(group.accounts)} + + + ); + }); + }; return ( @@ -171,47 +192,34 @@ export const ManageAccountBottomSheet: React.FC< {activeAccount?.accountName ?? ""} - - {truncateAddress(activeAccount?.publicKey ?? "")} - - - - {activeAccountActions.map((action) => ( - + + {truncateAddress(activeAccount?.publicKey ?? "")} + + - {action.icon} - - ))} + + + {/* Own gap: rows sit 16px apart, tighter than the 24px rhythm that - separates the hero, quick actions and the list as a whole. Skipped - entirely when there are no rows — an empty wrapper is still a flex child, - so it would claim a 24px slot in the column and leave a blank band while - the list loads. */} - {accounts.length > 0 && ( + separates the hero and the list as a whole. Skipped entirely when there + are no rows, or until the sources are read: an empty wrapper is still a + flex child, so it would claim a 24px slot in the column and leave a + blank band while the list loads. */} + {accounts.length > 0 && sources && ( - {accounts.map((account, index) => ( - - ))} + {renderList()} )} diff --git a/src/components/screens/HomeScreen/ManageAccounts.tsx b/src/components/screens/HomeScreen/ManageAccounts.tsx index 8771985fe..5777c7694 100644 --- a/src/components/screens/HomeScreen/ManageAccounts.tsx +++ b/src/components/screens/HomeScreen/ManageAccounts.tsx @@ -5,21 +5,19 @@ import AddWalletFooter from "components/screens/HomeScreen/AddWalletFooter"; import ManageAccountBottomSheet, { ManageAccountSheetHeader, } from "components/screens/HomeScreen/ManageAccountBottomSheet"; -import RenameAccountModal from "components/screens/HomeScreen/RenameAccountModal"; import { AnalyticsEvent } from "config/analyticsConfig"; import { ERROR_TOAST_DURATION } from "config/constants"; import { logger } from "config/logger"; import { RootStackParamList, ROOT_NAVIGATOR_ROUTES } from "config/routes"; -import { Account } from "config/types"; +import { Account, SecretSource } from "config/types"; import { useAccountsFiatTotalsStore } from "ducks/accountsFiatTotals"; import { ActiveAccount, useAuthenticationStore } from "ducks/auth"; -import { getStellarExpertUrl } from "helpers/stellarExpert"; import useAppTranslation from "hooks/useAppTranslation"; import { useClipboard } from "hooks/useClipboard"; -import { useInAppBrowser } from "hooks/useInAppBrowser"; import { useToast } from "providers/ToastProvider"; -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { analytics } from "services/analytics"; +import { getSecretSources } from "services/auth/secretSources"; interface ManageAccountsProps { accounts: Account[]; @@ -33,9 +31,9 @@ const ACCOUNT_SWITCH_DISMISS_DELAY_MS = 500; /** * Manage-accounts (wallets) bottom sheet orchestrator: owns the sheet - * lifecycle and all its actions — active-account quick actions (QR code, - * copy, explorer, rename), account switching with its post-switch dismiss - * delay, the rename modal, and the "+ Add wallet" footer. Presents through + * lifecycle and its actions: copying the active address, account switching + * with its post-switch dismiss delay, and the "+ Add wallet" footer. Reads the + * wallet's secret sources so the list can group by them. Presents through * the shared BottomSheet in scrollable mode with a pinned header/footer, * and triggers the TTL-gated per-account USD totals fetch on present. */ @@ -47,13 +45,8 @@ const ManageAccounts: React.FC = ({ isLoadingAccounts = false, }) => { const navigation = useNavigation>(); - const { - network, - renameAccount, - selectAccount, - isRenamingAccount, - isSwitchingAccount, - } = useAuthenticationStore(); + const { network, selectAccount, isSwitchingAccount } = + useAuthenticationStore(); const fiatTotals = useAccountsFiatTotalsStore((state) => state.fiatTotals); const isLoadingFiatTotals = useAccountsFiatTotalsStore( (state) => state.isLoading, @@ -62,17 +55,26 @@ const ManageAccounts: React.FC = ({ (state) => state.fetchAccountsFiatTotals, ); const { copyToClipboard } = useClipboard(); - const { open: openInAppBrowser } = useInAppBrowser(); const { showToast } = useToast(); const { t } = useAppTranslation(); - const [accountToRename, setAccountToRename] = useState(null); - const [renameAccountModalVisible, setRenameAccountModalVisible] = - useState(false); + const [sources, setSources] = useState(null); const [switchingToPublicKey, setSwitchingToPublicKey] = useState< string | null >(null); + // Read while the sheet is still closed (Home keeps this mounted), and again + // when the list changes, so the groups are ready when it opens. + useEffect(() => { + getSecretSources() + .then((map) => setSources(Object.values(map))) + .catch((error) => { + logger.error("ManageAccounts", "Failed to read secret sources", error); + // Still list the wallets, under the trailing group. + setSources((current) => current ?? []); + }); + }, [accounts]); + const handleSheetPresent = useCallback( (index: number) => { if (index < 0) { @@ -115,13 +117,6 @@ const ManageAccounts: React.FC = ({ navigation.navigate(ROOT_NAVIGATOR_ROUTES.SETTINGS_STACK); }, [navigation, bottomSheetRef]); - const handleOpenMyQRCode = useCallback(() => { - bottomSheetRef.current?.dismiss(); - navigation.navigate(ROOT_NAVIGATOR_ROUTES.SCAN_RECEIVE_SCREEN, { - initialTab: "receive", - }); - }, [navigation, bottomSheetRef]); - const handleCopyActiveAddress = useCallback(() => { if (!activeAccount) { return; @@ -134,42 +129,6 @@ const ManageAccounts: React.FC = ({ analytics.trackCopyPublicKey(); }, [activeAccount, copyToClipboard, t]); - const handleViewActiveOnExplorer = useCallback(() => { - if (!activeAccount) { - return; - } - - const url = `${getStellarExpertUrl(network)}/account/${activeAccount.publicKey}`; - analytics.track(AnalyticsEvent.VIEW_PUBLIC_KEY_CLICKED_STELLAR_EXPERT); - - openInAppBrowser(url).catch((error) => - logger.warn( - "ManageAccounts", - "Error opening account on stellar.expert:", - error, - ), - ); - }, [activeAccount, network, openInAppBrowser]); - - const handleOpenRenameActiveAccount = useCallback(() => { - if (!activeAccount) { - return; - } - - // Prefer the entry from the accounts list (it carries flags like - // importedFromSecretKey); fall back to mapping the active account. - const account = accounts.find( - (item) => item.publicKey === activeAccount.publicKey, - ) ?? { - id: activeAccount.id, - name: activeAccount.accountName, - publicKey: activeAccount.publicKey, - }; - - setAccountToRename(account); - setRenameAccountModalVisible(true); - }, [accounts, activeAccount]); - const handleAddAnotherWallet = useCallback(() => { if (!navigation) return; @@ -180,21 +139,6 @@ const ManageAccounts: React.FC = ({ navigation.navigate(ROOT_NAVIGATOR_ROUTES.MANAGE_WALLETS_STACK); }, [navigation, bottomSheetRef]); - const handleRenameAccount = useCallback( - async (newAccountName: string) => { - if (!accountToRename || !activeAccount) return; - - analytics.trackViewPublicKeyAccountRenamed(); - - await renameAccount({ - accountName: newAccountName, - publicKey: accountToRename.publicKey, - }); - setRenameAccountModalVisible(false); - }, - [accountToRename, activeAccount, renameAccount], - ); - const handleSelectAccount = useCallback( async (publicKey: string) => { // switchingToPublicKey covers the post-switch window where the auth @@ -272,51 +216,37 @@ const ManageAccounts: React.FC = ({ [handleAddAnotherWallet, isSwitchInProgress], ); + // Scrollable mode (same recipe as the history details sheet): the pinned + // header/footer live inside the sheet's content tree, so the whole sheet + // slides in as one unit, and dynamic sizing measures the content before + // animating. return ( - <> - {/* Scrollable mode (same recipe as the history details sheet): the - pinned header/footer live inside the sheet's content tree, so the - whole sheet slides in as one unit, and dynamic sizing measures the - content before animating. */} - - } - /> - - + + } + /> ); }; diff --git a/src/components/screens/HomeScreen/RenameAccountForm.tsx b/src/components/screens/HomeScreen/RenameAccountForm.tsx index 8f2e6dd72..c01edf9f2 100644 --- a/src/components/screens/HomeScreen/RenameAccountForm.tsx +++ b/src/components/screens/HomeScreen/RenameAccountForm.tsx @@ -23,8 +23,7 @@ export interface RenameAccountFormProps { /** * The rename form: avatar, a single autofocused name input and Cancel / Save - * name actions. Shared by the centered modal on Home and the bottom sheet on - * Edit account. + * name actions. Edit account shows it in a bottom sheet. */ const RenameAccountForm: React.FC = ({ account, diff --git a/src/components/screens/HomeScreen/RenameAccountModal.tsx b/src/components/screens/HomeScreen/RenameAccountModal.tsx deleted file mode 100644 index e5451963f..000000000 --- a/src/components/screens/HomeScreen/RenameAccountModal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import Modal from "components/Modal"; -import RenameAccountForm from "components/screens/HomeScreen/RenameAccountForm"; -import { DEFAULT_PADDING } from "config/constants"; -import { Account } from "config/types"; -import { pxValue } from "helpers/dimensions"; -import React from "react"; - -interface RenameAccountModalProps { - modalVisible: boolean; - setModalVisible: (visible: boolean) => void; - handleRenameAccount: (newAccountName: string) => Promise; - /** Account being renamed; null until a rename is requested. */ - account: Account | null; - isRenamingAccount: boolean; -} - -/** - * Modal for renaming a wallet. Uses the shared Modal's `position="keyboard"` - * mode so the card slides with the keyboard instead of jumping. - */ -const RenameAccountModal: React.FC = ({ - modalVisible, - setModalVisible, - handleRenameAccount, - account, - isRenamingAccount, -}) => ( - setModalVisible(false)} - closeOnOverlayPress={false} - position="keyboard" - contentClassName="bg-background-primary rounded-[32px] w-full" - contentStyle={{ padding: pxValue(DEFAULT_PADDING) }} - > - setModalVisible(false)} - onSave={handleRenameAccount} - /> - -); - -export default RenameAccountModal; diff --git a/src/helpers/accountGroups.ts b/src/helpers/accountGroups.ts index 2bc7c57ea..1d63ea4ba 100644 --- a/src/helpers/accountGroups.ts +++ b/src/helpers/accountGroups.ts @@ -10,6 +10,7 @@ export enum AccountGroupKind { } export interface AccountGroup { + /** Stable across renders and sheet reopenings; keys collapsed state. */ key: string; kind: AccountGroupKind; label: string; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 7765b5678..06b63a38c 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -56,12 +56,7 @@ }, "manageAccount": { "addWallet": "Add wallet", - "renameWallet": "Rename wallet", - "copyAddress": "Copy address", - "viewOnExplorer": "View on stellar.expert" - }, - "account": { - "imported": "Imported" + "copyAddress": "Copy address" } }, "tabBar": { diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 3f8db3f17..844beea3c 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -56,12 +56,7 @@ }, "manageAccount": { "addWallet": "Adicionar carteira", - "renameWallet": "Renomear carteira", - "copyAddress": "Copiar endereço", - "viewOnExplorer": "Ver no stellar.expert" - }, - "account": { - "imported": "Importada" + "copyAddress": "Copiar endereço" } }, "tabBar": { From 0ca2a05ec3631f72d91ec449ccae4c8cfb159de2 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Tue, 10 Nov 2026 15:15:07 -0700 Subject: [PATCH 04/14] feat(accounts): add wallet chooser and source-selected account creation Port of stellar/freighter#3082. Add wallet now opens an "Add account" chooser with Create new wallet and Import Secret Key. Create new wallet lists the wallet's recovery phrases, so an account can be added under an existing phrase or under a newly generated one. Each new account is named in a sheet prefilled with the next free "Account N". A new phrase is held in auth module state, never in store state or navigation params, until the user backs it up and confirms with their password. Each generation gets an id that the screens pass along, and saving refuses a phrase that a later generation replaced. The held phrase is cleared once saved, when the backup screen closes, and on lock, logout or wipe. Import Recovery Phrase and Connect Hardware Wallet are left out. Mobile has no hardware support, and importing a phrase still replaces the whole wallet. --- .../screens/AddAnotherWallet.test.tsx | 43 +++ .../BackupRecoveryPhraseScreen.test.tsx | 97 +++++++ .../screens/CreateWalletScreen.test.tsx | 188 +++++++++++++ .../HomeScreen/AccountItemRow.test.tsx | 12 + .../screens/VerifyPasswordScreen.test.tsx | 107 +++++++ __tests__/ducks/auth.test.ts | 259 +++++++++++++++++ __tests__/helpers/accountName.test.ts | 42 +++ src/components/AccountNameForm.tsx | 124 ++++++++ src/components/AddWalletOption.tsx | 70 +++++ src/components/ScreenTitle.tsx | 33 +++ src/components/screens/AddAnotherWallet.tsx | 96 +++---- .../screens/BackupRecoveryPhraseScreen.tsx | 129 +++++++++ src/components/screens/CreateWalletScreen.tsx | 264 ++++++++++++++++++ .../screens/HomeScreen/AccountItemRow.tsx | 21 +- .../screens/HomeScreen/RenameAccountForm.tsx | 90 ++---- .../screens/RecoveryPhraseScreen.tsx | 4 +- .../screens/VerifyPasswordScreen.tsx | 50 +++- src/config/routes.ts | 19 +- src/ducks/auth.ts | 233 +++++++++++++++- src/helpers/accountName.ts | 40 +++ src/i18n/locales/en/translations.json | 33 ++- src/i18n/locales/pt/translations.json | 33 ++- src/navigators/ManageWalletsNavigator.tsx | 26 +- 23 files changed, 1850 insertions(+), 163 deletions(-) create mode 100644 __tests__/components/screens/AddAnotherWallet.test.tsx create mode 100644 __tests__/components/screens/BackupRecoveryPhraseScreen.test.tsx create mode 100644 __tests__/components/screens/CreateWalletScreen.test.tsx create mode 100644 __tests__/components/screens/VerifyPasswordScreen.test.tsx create mode 100644 __tests__/helpers/accountName.test.ts create mode 100644 src/components/AccountNameForm.tsx create mode 100644 src/components/AddWalletOption.tsx create mode 100644 src/components/ScreenTitle.tsx create mode 100644 src/components/screens/BackupRecoveryPhraseScreen.tsx create mode 100644 src/components/screens/CreateWalletScreen.tsx create mode 100644 src/helpers/accountName.ts diff --git a/__tests__/components/screens/AddAnotherWallet.test.tsx b/__tests__/components/screens/AddAnotherWallet.test.tsx new file mode 100644 index 000000000..2a8119912 --- /dev/null +++ b/__tests__/components/screens/AddAnotherWallet.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent } from "@testing-library/react-native"; +import AddAnotherWalletScreen from "components/screens/AddAnotherWallet"; +import { MANAGE_WALLETS_ROUTES } from "config/routes"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +const navigation = { navigate: jest.fn() }; + +const renderScreen = () => + renderWithProviders( + , + ); + +describe("AddAnotherWalletScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + ["add-wallet-create", MANAGE_WALLETS_ROUTES.CREATE_WALLET_SCREEN], + [ + "add-wallet-import-secret-key", + MANAGE_WALLETS_ROUTES.IMPORT_SECRET_KEY_SCREEN, + ], + ])("%s opens %s", (testID, route) => { + const { getByTestId } = renderScreen(); + + fireEvent.press(getByTestId(testID)); + + expect(navigation.navigate).toHaveBeenCalledWith(route); + }); + + it("offers only the ways mobile supports", () => { + const { getByText, queryByText } = renderScreen(); + + expect(getByText("Add account")).toBeTruthy(); + expect(queryByText("Import Recovery Phrase")).toBeNull(); + expect(queryByText("Connect Hardware Wallet")).toBeNull(); + }); +}); diff --git a/__tests__/components/screens/BackupRecoveryPhraseScreen.test.tsx b/__tests__/components/screens/BackupRecoveryPhraseScreen.test.tsx new file mode 100644 index 000000000..94f642075 --- /dev/null +++ b/__tests__/components/screens/BackupRecoveryPhraseScreen.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent } from "@testing-library/react-native"; +import BackupRecoveryPhraseScreen from "components/screens/BackupRecoveryPhraseScreen"; +import { MANAGE_WALLETS_ROUTES } from "config/routes"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +const PHRASE = + "gloom student label strategy tattoo promote brand mushroom problem divert carbon erode"; +const WORDS = PHRASE.split(" "); + +jest.mock("@react-native-community/blur", () => ({ BlurView: "BlurView" })); + +const mockCopyToClipboard = jest.fn(); +jest.mock("hooks/useSecureClipboard", () => ({ + useSecureClipboard: () => ({ copyToClipboard: mockCopyToClipboard }), +})); + +const PENDING_ID = "pending-phrase-7"; + +let mockPendingPhrase: string | null = PHRASE; +const mockGetPending = jest.fn(); +const mockClearPending = jest.fn(); +jest.mock("ducks/auth", () => ({ + getPendingRecoveryPhrase: (id: string) => { + mockGetPending(id); + return mockPendingPhrase; + }, + clearPendingRecoveryPhrase: (id?: string) => mockClearPending(id), + // CopyPhraseButton's module (the onboarding screen) reads the store. + useAuthenticationStore: ( + selector: (state: { isLoading: boolean }) => unknown, + ) => selector({ isLoading: false }), +})); + +const navigation = { navigate: jest.fn(), goBack: jest.fn() }; + +const renderScreen = () => + renderWithProviders( + , + ); + +describe("BackupRecoveryPhraseScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPendingPhrase = PHRASE; + }); + + it("keeps the held phrase off the screen until the user reveals it", () => { + const { getByText, queryByText } = renderScreen(); + + expect(mockGetPending).toHaveBeenCalledWith(PENDING_ID); + expect(getByText("Back Up Your New Recovery Phrase")).toBeTruthy(); + WORDS.forEach((word) => expect(queryByText(word)).toBeNull()); + }); + + it("reveals the phrase, copies it securely, and goes on to the password step", () => { + const { getByTestId, getByText } = renderScreen(); + + fireEvent.press(getByTestId("backup-recovery-phrase-reveal")); + WORDS.forEach((word) => expect(getByText(word)).toBeTruthy()); + + fireEvent.press(getByTestId("copy-recovery-phrase-button")); + expect(mockCopyToClipboard).toHaveBeenCalledWith(PHRASE, { + hideNotification: true, + }); + + fireEvent.press(getByText("Create")); + // The phrase stays in the store, out of the params. + expect(navigation.navigate).toHaveBeenCalledWith( + MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN, + { kind: "newPhrase", pendingPhraseId: PENDING_ID, accountName: "Travel" }, + ); + }); + + it("drops its own generation when the screen goes away", () => { + const { unmount } = renderScreen(); + + unmount(); + + expect(mockClearPending).toHaveBeenCalledTimes(1); + expect(mockClearPending).toHaveBeenCalledWith(PENDING_ID); + }); + + it("goes back when its generation is no longer held", () => { + mockPendingPhrase = null; + renderScreen(); + + expect(navigation.goBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/components/screens/CreateWalletScreen.test.tsx b/__tests__/components/screens/CreateWalletScreen.test.tsx new file mode 100644 index 000000000..ff7f2275c --- /dev/null +++ b/__tests__/components/screens/CreateWalletScreen.test.tsx @@ -0,0 +1,188 @@ +import { fireEvent, waitFor, within } from "@testing-library/react-native"; +import CreateWalletScreen from "components/screens/CreateWalletScreen"; +import { MANAGE_WALLETS_ROUTES } from "config/routes"; +import { Account } from "config/types"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; +import { getSecretSources } from "services/auth/secretSources"; + +jest.mock("@react-navigation/native", () => { + const { useEffect } = jest.requireActual("react"); + return { + ...jest.requireActual("@react-navigation/native"), + useFocusEffect: (effect: () => void) => useEffect(effect, [effect]), + }; +}); + +const mockPresent = jest.fn(); +const mockDismiss = jest.fn(); +jest.mock("components/BottomSheet", () => { + const { useImperativeHandle } = jest.requireActual("react"); + return ({ + modalRef, + customContent, + }: { + modalRef: React.Ref; + customContent: React.ReactNode; + }) => { + useImperativeHandle(modalRef, () => ({ + present: mockPresent, + dismiss: mockDismiss, + })); + return customContent; + }; +}); + +// The real field needs a live bottom sheet when isBottomSheetInput is set. +jest.mock("components/sds/Input", () => { + const { TextInput } = jest.requireActual("react-native"); + return { + Input: ({ + value, + onChangeText, + testID, + }: { + value: string; + onChangeText: (text: string) => void; + testID?: string; + }) => ( + + ), + }; +}); + +jest.mock("services/auth/secretSources", () => ({ + getSecretSources: jest.fn(), +})); + +const phraseSources = { + "phrase-1": { + id: "phrase-1", + type: "phrase", + label: "Seed Phrase 1", + createdAt: 1, + }, + "phrase-2": { + id: "phrase-2", + type: "phrase", + label: "Seed Phrase 2", + createdAt: 3, + }, + "key-1": { + id: "key-1", + type: "secret_key", + label: "Secret Key 1", + createdAt: 2, + }, +}; + +const accounts: Account[] = [ + { id: "a", sourceId: "phrase-1", name: "Account 1", publicKey: "GA" }, + { id: "b", sourceId: "key-1", name: "Imported", publicKey: "GB" }, + { id: "c", sourceId: "phrase-2", name: "Account 2", publicKey: "GC" }, +]; + +const mockGeneratePhrase = jest.fn(); +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: () => ({ + allAccounts: accounts, + getAllAccounts: jest.fn(), + network: "PUBLIC", + }), + generatePendingRecoveryPhrase: () => mockGeneratePhrase(), +})); + +jest.mock("hooks/useGetActiveAccount", () => ({ + __esModule: true, + default: () => ({ account: { id: "a", publicKey: "GA" } }), +})); + +jest.mock("ducks/accountsFiatTotals", () => ({ + useAccountsFiatTotalsStore: ( + selector: (state: Record) => unknown, + ) => + selector({ + fiatTotals: {}, + isLoading: false, + fetchAccountsFiatTotals: jest.fn(), + }), +})); + +const navigation = { navigate: jest.fn() }; + +const renderScreen = () => + renderWithProviders( + , + ); + +describe("CreateWalletScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + (getSecretSources as jest.Mock).mockResolvedValue(phraseSources); + }); + + it("lists each phrase with its accounts, and leaves out imported keys", async () => { + const { findAllByTestId, queryByText } = renderScreen(); + + const groups = await findAllByTestId("create-wallet-group"); + expect(groups).toHaveLength(2); + expect(within(groups[0]).getByText("Recovery phrase 1")).toBeTruthy(); + expect(within(groups[0]).getByText("Account 1")).toBeTruthy(); + expect(within(groups[1]).getByText("Recovery phrase 2")).toBeTruthy(); + expect(within(groups[1]).getByText("Account 2")).toBeTruthy(); + expect(queryByText("Imported")).toBeNull(); + }); + + it("offers only a new phrase in a wallet with none", async () => { + (getSecretSources as jest.Mock).mockResolvedValue({ + "key-1": phraseSources["key-1"], + }); + const { findByTestId, queryAllByTestId } = renderScreen(); + + expect(await findByTestId("create-wallet-generate")).toBeTruthy(); + expect(queryAllByTestId("create-wallet-group")).toHaveLength(0); + }); + + it("holds the list back until the sources are read", () => { + (getSecretSources as jest.Mock).mockReturnValue(new Promise(() => {})); + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("create-wallet")).toBeNull(); + }); + + it("names the account, then asks for the password to add it to that phrase", async () => { + const { findAllByTestId, getByTestId } = renderScreen(); + + const [, addToSecond] = await findAllByTestId("create-wallet-add-account"); + fireEvent.press(addToSecond); + expect(mockPresent).toHaveBeenCalledTimes(1); + + // Prefilled with the next free default name; the i18n mock in tests + // resolves "authStore.account" to "Account {{number}}". + expect(getByTestId("set-account-name-input").props.value).toBe("Account 4"); + fireEvent.changeText(getByTestId("set-account-name-input"), " Savings "); + fireEvent.press(getByTestId("set-account-name-submit")); + + expect(mockDismiss).toHaveBeenCalled(); + expect(mockGeneratePhrase).not.toHaveBeenCalled(); + expect(navigation.navigate).toHaveBeenCalledWith( + MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN, + { kind: "existingPhrase", sourceId: "phrase-2", accountName: "Savings" }, + ); + }); + + it("generates a phrase and goes to its backup for a new phrase", async () => { + mockGeneratePhrase.mockReturnValue("pending-phrase-3"); + const { findByTestId, getByTestId, getByText } = renderScreen(); + + fireEvent.press(await findByTestId("create-wallet-generate")); + expect(getByText("Next")).toBeTruthy(); + fireEvent.press(getByTestId("set-account-name-submit")); + + await waitFor(() => expect(mockGeneratePhrase).toHaveBeenCalledTimes(1)); + expect(navigation.navigate).toHaveBeenCalledWith( + MANAGE_WALLETS_ROUTES.BACKUP_RECOVERY_PHRASE_SCREEN, + { pendingPhraseId: "pending-phrase-3", accountName: "Account 4" }, + ); + }); +}); diff --git a/__tests__/components/screens/HomeScreen/AccountItemRow.test.tsx b/__tests__/components/screens/HomeScreen/AccountItemRow.test.tsx index bf5e08c9e..2ba445a16 100644 --- a/__tests__/components/screens/HomeScreen/AccountItemRow.test.tsx +++ b/__tests__/components/screens/HomeScreen/AccountItemRow.test.tsx @@ -212,4 +212,16 @@ describe("AccountItemRow", () => { "my account 1", ); }); + + it("is a plain listing, not a button, without a select handler", () => { + const { handleSelectAccount, ...listingProps } = defaultProps; + const { getByTestId } = renderWithProviders( + , + ); + + const row = getByTestId("account-row-0-select"); + expect(row.props.accessibilityRole).toBeUndefined(); + fireEvent.press(row); + expect(handleSelectAccount).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/components/screens/VerifyPasswordScreen.test.tsx b/__tests__/components/screens/VerifyPasswordScreen.test.tsx new file mode 100644 index 000000000..7274d7801 --- /dev/null +++ b/__tests__/components/screens/VerifyPasswordScreen.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, waitFor } from "@testing-library/react-native"; +import VerifyPasswordScreen from "components/screens/VerifyPasswordScreen"; +import { CreateWalletTarget, ROOT_NAVIGATOR_ROUTES } from "config/routes"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +const mockCreateAccount = jest.fn(); +const mockAddRecoveryPhraseSource = jest.fn(); +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: () => ({ + createAccount: mockCreateAccount, + addRecoveryPhraseSource: mockAddRecoveryPhraseSource, + isCreatingAccount: false, + error: null, + clearError: jest.fn(), + }), + getActiveAccountPublicKey: jest.fn().mockResolvedValue("GA"), +})); + +// The template's own behavior (biometrics, the field) is covered elsewhere; +// here it only hands the password to the screen. +jest.mock("components/templates/InputPasswordTemplate", () => { + const { TouchableOpacity } = jest.requireActual("react-native"); + return ({ + handleContinue, + }: { + handleContinue: (password: string) => void; + }) => ( + handleContinue("hunter2")} + /> + ); +}); + +const navigation = { reset: jest.fn() }; + +const renderScreen = (params: CreateWalletTarget) => + renderWithProviders( + , + ); + +describe("VerifyPasswordScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateAccount.mockResolvedValue(undefined); + mockAddRecoveryPhraseSource.mockResolvedValue(undefined); + }); + + it("adds a named account under the chosen phrase", async () => { + const { getByTestId } = renderScreen({ + kind: "existingPhrase", + sourceId: "phrase-2", + accountName: "Savings", + }); + + fireEvent.press(getByTestId("continue")); + + expect(mockCreateAccount).toHaveBeenCalledWith( + "hunter2", + "phrase-2", + "Savings", + ); + expect(mockAddRecoveryPhraseSource).not.toHaveBeenCalled(); + await waitFor(() => + expect(navigation.reset).toHaveBeenCalledWith({ + index: 0, + routes: [{ name: ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK }], + }), + ); + }); + + it("stores the new phrase with its named first account", async () => { + const { getByTestId } = renderScreen({ + kind: "newPhrase", + pendingPhraseId: "pending-phrase-1", + accountName: "Travel", + }); + + fireEvent.press(getByTestId("continue")); + + expect(mockAddRecoveryPhraseSource).toHaveBeenCalledWith( + "hunter2", + "pending-phrase-1", + "Travel", + ); + expect(mockCreateAccount).not.toHaveBeenCalled(); + await waitFor(() => expect(navigation.reset).toHaveBeenCalled()); + }); + + it("stays on the screen when the create fails", async () => { + mockAddRecoveryPhraseSource.mockRejectedValue(new Error("nope")); + const { getByTestId } = renderScreen({ + kind: "newPhrase", + pendingPhraseId: "pending-phrase-1", + accountName: "Travel", + }); + + fireEvent.press(getByTestId("continue")); + + await waitFor(() => expect(mockAddRecoveryPhraseSource).toHaveBeenCalled()); + expect(navigation.reset).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 015746e39..9f74442cd 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -24,6 +24,9 @@ import { revealAccountSecretKey, revealRecoveryPhrase, getHeldSecrets, + generatePendingRecoveryPhrase, + getPendingRecoveryPhrase, + clearPendingRecoveryPhrase, } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useCollectiblesStore } from "ducks/collectibles"; @@ -141,6 +144,7 @@ jest.mock("stellar-hd-wallet", () => ({ __esModule: true, default: { fromMnemonic: jest.fn(), + generateMnemonic: jest.fn(), }, })); @@ -348,6 +352,8 @@ describe("auth duck", () => { importSecretKey: useAuthenticationStore.getState().importSecretKey, renameAccount: useAuthenticationStore.getState().renameAccount, createAccount: useAuthenticationStore.getState().createAccount, + addRecoveryPhraseSource: + useAuthenticationStore.getState().addRecoveryPhraseSource, initializeNetwork: useAuthenticationStore.getState().initializeNetwork, softLock: useAuthenticationStore.getState().softLock, navigateToLockScreen: @@ -4945,6 +4951,259 @@ describe("auth duck", () => { ]); expect(storedIndices()).toEqual({ [mockPhraseSourceId]: [0, 1, 2] }); }); + + describe("account names", () => { + it("names the new account as the caller asks, trimmed", async () => { + const { result } = mockAddAccountSession({ + accounts: [phraseAccount(0)], + indices: { [mockPhraseSourceId]: [0] }, + }); + + await act(async () => { + await result.current.createAccount( + mockPassword, + mockPhraseSourceId, + " Savings ", + ); + }); + + expect(storedAccounts()[1].name).toBe("Savings"); + }); + + it("falls back to the default name for a blank one", async () => { + const { result } = mockAddAccountSession({ + accounts: [phraseAccount(0)], + indices: { [mockPhraseSourceId]: [0] }, + }); + + await act(async () => { + await result.current.createAccount( + mockPassword, + mockPhraseSourceId, + " ", + ); + }); + + // The i18next mock returns the key. + expect(storedAccounts()[1].name).toBe("authStore.account"); + }); + }); + + describe("addRecoveryPhraseSource", () => { + const newPhrase = "brand new phrase"; + const newSourceId = `phrase:${newPhrase}`; + + const mockNewPhraseSession = () => { + (StellarHDWallet.generateMnemonic as jest.Mock).mockReturnValue( + newPhrase, + ); + const hook = mockAddAccountSession({ + accounts: [phraseAccount(0)], + indices: { [mockPhraseSourceId]: [0] }, + }); + act(() => { + useAuthenticationStore.setState({ + addRecoveryPhraseSource: + originalStoreMethods.addRecoveryPhraseSource, + }); + }); + return hook; + }; + + afterEach(() => { + clearPendingRecoveryPhrase(); + }); + + it("stores the held phrase as a new source with its first account, named as asked", async () => { + const { result } = mockNewPhraseSession(); + const id = generatePendingRecoveryPhrase(); + expect(getPendingRecoveryPhrase(id)).toBe(newPhrase); + + await act(async () => { + await result.current.addRecoveryPhraseSource( + mockPassword, + id, + "Travel", + ); + }); + + expect(result.current.error).toBeNull(); + expect(storedPublicKeys()).toEqual([publicKeyAt(newPhrase, 0)]); + expect(storedAccounts()[1]).toEqual( + expect.objectContaining({ + id: "new-account", + name: "Travel", + sourceId: newSourceId, + publicKey: publicKeyAt(newPhrase, 0), + }), + ); + expect(storedIndices()).toEqual({ + [mockPhraseSourceId]: [0], + [newSourceId]: [0], + }); + expect(storedSources()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: newSourceId, + type: SecretSourceType.PHRASE, + }), + ]), + ); + // Held beside the wallet's phrase, not in place of it. + const [written] = writtenTemporaryStores(); + expect(written.mnemonics).toEqual({ + [mockPhraseSourceId]: mockMnemonicPhrase, + [newSourceId]: newPhrase, + }); + expect(dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID]).toBe( + "new-account", + ); + expect(getPendingRecoveryPhrase(id)).toBeNull(); + expect(linkWalletSources).toHaveBeenCalledTimes(1); + }); + + it("refuses when no phrase is held", async () => { + const { result } = mockNewPhraseSession(); + + await act(async () => { + await expect( + result.current.addRecoveryPhraseSource( + mockPassword, + "pending-phrase-0", + ), + ).rejects.toThrow("authStore.error.pendingRecoveryPhraseMissing"); + }); + + expect(result.current.error).toBe( + "authStore.error.pendingRecoveryPhraseMissing", + ); + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + }); + + it("stores nothing and keeps the phrase on a wrong password", async () => { + const { result } = mockNewPhraseSession(); + const id = generatePendingRecoveryPhrase(); + sharedKeyManager.loadKey.mockRejectedValue(new Error("bad password")); + + await act(async () => { + await expect( + result.current.addRecoveryPhraseSource("wrong", id), + ).rejects.toThrow(); + }); + + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + expect(storedSources()).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: newSourceId }), + ]), + ); + // The user can retry without generating a new phrase. + expect(getPendingRecoveryPhrase(id)).toBe(newPhrase); + }); + + it("refuses a phrase the wallet already holds", async () => { + const { result } = mockNewPhraseSession(); + (StellarHDWallet.generateMnemonic as jest.Mock).mockReturnValue( + mockMnemonicPhrase, + ); + const id = generatePendingRecoveryPhrase(); + dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES] = JSON.stringify({ + [mockPhraseSourceId]: { + id: mockPhraseSourceId, + type: SecretSourceType.PHRASE, + label: "Seed Phrase 1", + createdAt: 1, + }, + }); + + await act(async () => { + await expect( + result.current.addRecoveryPhraseSource(mockPassword, id), + ).rejects.toThrow("authStore.error.recoveryPhraseAlreadyHeld"); + }); + + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + }); + + it("drops the held phrase when the wallet soft-locks", async () => { + mockNewPhraseSession(); + const id = generatePendingRecoveryPhrase(); + act(() => { + useAuthenticationStore.setState({ + softLock: originalStoreMethods.softLock, + }); + }); + + await act(async () => { + await useAuthenticationStore.getState().softLock(); + }); + + expect(getPendingRecoveryPhrase(id)).toBeNull(); + }); + + it("refuses a generation a later one replaced", async () => { + const { result } = mockNewPhraseSession(); + const shown = generatePendingRecoveryPhrase(); + (StellarHDWallet.generateMnemonic as jest.Mock).mockReturnValue( + "another new phrase", + ); + const later = generatePendingRecoveryPhrase(); + expect(later).not.toBe(shown); + + await act(async () => { + await expect( + result.current.addRecoveryPhraseSource(mockPassword, shown), + ).rejects.toThrow("authStore.error.pendingRecoveryPhraseMissing"); + }); + + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + expect(getPendingRecoveryPhrase(shown)).toBeNull(); + expect(getPendingRecoveryPhrase(later)).toBe("another new phrase"); + }); + + it("refuses a confirmation without a generation id", async () => { + const { result } = mockNewPhraseSession(); + generatePendingRecoveryPhrase(); + + await act(async () => { + await expect( + result.current.addRecoveryPhraseSource(mockPassword, ""), + ).rejects.toThrow("authStore.error.pendingRecoveryPhraseMissing"); + }); + + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + }); + + it("clears only the generation it names", () => { + const shown = generatePendingRecoveryPhrase(); + const later = generatePendingRecoveryPhrase(); + + clearPendingRecoveryPhrase(shown); + expect(getPendingRecoveryPhrase(later)).toBe(newPhrase); + + clearPendingRecoveryPhrase(later); + expect(getPendingRecoveryPhrase(later)).toBeNull(); + }); + + // The extension refuses a wallet with no software keystore, since + // every password would fail as if wrong. Mobile has no hardware + // accounts, but the same case gets its own error here, not + // "invalid password", and nothing is written. + it("refuses a wallet with no keystore to check the password against", async () => { + const { result } = mockNewPhraseSession(); + const id = generatePendingRecoveryPhrase(); + sharedKeyManager.loadAllKeyIds.mockResolvedValueOnce([]); + + await act(async () => { + await expect( + result.current.addRecoveryPhraseSource(mockPassword, id), + ).rejects.toThrow("authStore.error.noKeyPairFound"); + }); + + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + expect(getPendingRecoveryPhrase(id)).toBe(newPhrase); + }); + }); }); // Port of the extension's account provenance (#75): type and source live diff --git a/__tests__/helpers/accountName.test.ts b/__tests__/helpers/accountName.test.ts new file mode 100644 index 000000000..caacfca60 --- /dev/null +++ b/__tests__/helpers/accountName.test.ts @@ -0,0 +1,42 @@ +import { + getNextDefaultAccountName, + normalizeAccountName, +} from "helpers/accountName"; + +const formatName = (number: number) => `Account ${number}`; + +describe("getNextDefaultAccountName", () => { + it("starts at Account 1 in an empty wallet", () => { + expect(getNextDefaultAccountName([], formatName)).toBe("Account 1"); + }); + + it("counts every account in the wallet", () => { + expect( + getNextDefaultAccountName(["Account 1", "Savings"], formatName), + ).toBe("Account 3"); + }); + + it("skips names already in use", () => { + expect( + getNextDefaultAccountName(["Account 3", "Account 4"], formatName), + ).toBe("Account 5"); + }); + + it("stops when the format ignores the number", () => { + expect(getNextDefaultAccountName(["Account"], () => "Account")).toBe( + "Account", + ); + }); +}); + +describe("normalizeAccountName", () => { + it("trims and caps the name", () => { + expect(normalizeAccountName(" Savings ")).toBe("Savings"); + expect(normalizeAccountName("x".repeat(30))).toBe("x".repeat(24)); + }); + + it("returns null for an empty or missing name", () => { + expect(normalizeAccountName(" ")).toBeNull(); + expect(normalizeAccountName(undefined)).toBeNull(); + }); +}); diff --git a/src/components/AccountNameForm.tsx b/src/components/AccountNameForm.tsx new file mode 100644 index 000000000..bb9fe3582 --- /dev/null +++ b/src/components/AccountNameForm.tsx @@ -0,0 +1,124 @@ +import { BottomSheetHeader } from "components/BottomSheetHeader"; +import { Avatar } from "components/sds/Avatar"; +import { Button } from "components/sds/Button"; +import { Input } from "components/sds/Input"; +import { + ACCOUNT_NAME_MAX_LENGTH, + ACCOUNT_NAME_MIN_LENGTH, +} from "config/constants"; +import useAppTranslation from "hooks/useAppTranslation"; +import React, { useEffect, useMemo, useState } from "react"; +import { View } from "react-native"; + +export interface AccountNameFormProps { + title: string; + /** The name the field starts with; it resets when this changes. */ + initialName: string; + /** The account's address, for its avatar. Absent before it exists. */ + publicKey?: string; + submitLabel: string; + /** Shows Cancel beside the submit button, as rename does. */ + showCancel?: boolean; + isSubmitting?: boolean; + onSubmit: (accountName: string) => void | Promise; + onClose: () => void; + /** Set inside a bottom sheet so the field works with its keyboard handling. */ + isBottomSheetInput?: boolean; + testID?: string; +} + +/** + * The account name sheet body: avatar, a single autofocused name input and + * the actions. Rename uses it with Cancel / Save name; Create new wallet uses + * it with the next default name and a single Create or Next. + */ +const AccountNameForm: React.FC = ({ + title, + initialName, + publicKey, + submitLabel, + showCancel = false, + isSubmitting = false, + onSubmit, + onClose, + isBottomSheetInput = false, + testID = "account-name-form", +}) => { + const { t } = useAppTranslation(); + const [accountName, setAccountName] = useState(initialName); + + useEffect(() => { + setAccountName(initialName); + }, [initialName]); + + // Trimmed before measuring so a whitespace-only name counts as empty. + const isAccountNameValid = useMemo( + () => + accountName.trim().length >= ACCOUNT_NAME_MIN_LENGTH && + accountName.trim().length <= ACCOUNT_NAME_MAX_LENGTH, + [accountName], + ); + + const submitButton = ( + + ); + + return ( + + + + + + + + + {showCancel ? ( + + + + + {submitButton} + + ) : ( + submitButton + )} + + ); +}; + +export default AccountNameForm; diff --git a/src/components/AddWalletOption.tsx b/src/components/AddWalletOption.tsx new file mode 100644 index 000000000..d51859b90 --- /dev/null +++ b/src/components/AddWalletOption.tsx @@ -0,0 +1,70 @@ +import { Text } from "components/sds/Typography"; +import { DEFAULT_PRESS_DELAY } from "config/constants"; +import useColors from "hooks/useColors"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +/** The 16pt glyph inside the option's 32pt circle. */ +export const ADD_WALLET_OPTION_ICON_SIZE = 16; + +interface AddWalletOptionProps { + /** Rendered at ADD_WALLET_OPTION_ICON_SIZE in `foreground.secondary`. */ + icon: React.ReactNode; + title: string; + description?: string; + onPress: () => void; + testID?: string; + accessibilityLabel?: string; +} + +/** + * The circled icon of an add-wallet option, also used by Create new wallet's + * Add account rows. + */ +export const AddWalletOptionIcon: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( + + {children} + +); + +/** + * One way to add a wallet: a card with a circled icon, a title and a line + * of description. Shared by Add account and Create new wallet. + */ +const AddWalletOption: React.FC = ({ + icon, + title, + description, + onPress, + testID, + accessibilityLabel, +}) => { + const { themeColors } = useColors(); + + return ( + + {icon} + + + {title} + + {description ? ( + + {description} + + ) : null} + + + ); +}; + +export default AddWalletOption; diff --git a/src/components/ScreenTitle.tsx b/src/components/ScreenTitle.tsx new file mode 100644 index 000000000..ab58077d9 --- /dev/null +++ b/src/components/ScreenTitle.tsx @@ -0,0 +1,33 @@ +import { OnboardTitle } from "components/layout/OnboardLayout"; +import { Text } from "components/sds/Typography"; +import React from "react"; +import { View } from "react-native"; + +interface ScreenTitleProps { + title: string; + subtitle?: React.ReactNode; + /** A muted subtitle, for explanatory copy. */ + isSubtitleSecondary?: boolean; +} + +/** + * The large left-aligned title the Varial designs open screens with + * ("Heading/Screen", 32/40 medium Funnel Sans), under a header that carries + * only the back or close button. + */ +const ScreenTitle: React.FC = ({ + title, + subtitle, + isSubtitleSecondary = false, +}) => ( + + {title} + {subtitle ? ( + + {subtitle} + + ) : null} + +); + +export default ScreenTitle; diff --git a/src/components/screens/AddAnotherWallet.tsx b/src/components/screens/AddAnotherWallet.tsx index 14e4acfb9..58519dee4 100644 --- a/src/components/screens/AddAnotherWallet.tsx +++ b/src/components/screens/AddAnotherWallet.tsx @@ -1,7 +1,10 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import AddWalletOption, { + ADD_WALLET_OPTION_ICON_SIZE, +} from "components/AddWalletOption"; +import ScreenTitle from "components/ScreenTitle"; import { BaseLayout } from "components/layout/BaseLayout"; import Icon from "components/sds/Icon"; -import { Text } from "components/sds/Typography"; import { MANAGE_WALLETS_ROUTES, ManageWalletsStackParamList, @@ -9,69 +12,66 @@ import { import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import React from "react"; -import { TouchableOpacity, View } from "react-native"; +import { View } from "react-native"; type AddAnotherWalletScreenProps = NativeStackScreenProps< ManageWalletsStackParamList, typeof MANAGE_WALLETS_ROUTES.ADD_ANOTHER_WALLET_SCREEN >; +/** + * The ways to add a wallet, opened from the account switcher's Add wallet. + * Importing a further recovery phrase and connecting a hardware wallet are + * in the design but not offered yet: an import still replaces the wallet, + * and mobile has no hardware support. + */ const AddAnotherWalletScreen: React.FC = ({ navigation, }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); - - const handleCreateAccount = () => { - navigation.navigate(MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN); - }; - - const handleImportSecretKey = () => { - navigation.navigate(MANAGE_WALLETS_ROUTES.IMPORT_SECRET_KEY_SCREEN); - }; + const iconColor = themeColors.foreground.secondary; return ( - - - + + + + } + title={t("addAnotherWalletScreen.actions.createNewWallet")} + description={t( + "addAnotherWalletScreen.actions.createNewWalletDescription", + )} + onPress={() => + navigation.navigate(MANAGE_WALLETS_ROUTES.CREATE_WALLET_SCREEN) + } + testID="add-wallet-create" /> - - - {t("addAnotherWalletScreen.actions.createNewWallet")} - - - {t("addAnotherWalletScreen.actions.createNewWalletDescription")} - - - - - + } + title={t("addAnotherWalletScreen.actions.importSecretKey")} + description={t( + "addAnotherWalletScreen.actions.importSecretKeyDescription", + )} + onPress={() => + navigation.navigate( + MANAGE_WALLETS_ROUTES.IMPORT_SECRET_KEY_SCREEN, + ) + } + testID="add-wallet-import-secret-key" /> - - - {t("addAnotherWalletScreen.actions.importSecretKey")} - - - {t("addAnotherWalletScreen.actions.importSecretKeyDescription")} - - + ); diff --git a/src/components/screens/BackupRecoveryPhraseScreen.tsx b/src/components/screens/BackupRecoveryPhraseScreen.tsx new file mode 100644 index 000000000..b95589ac9 --- /dev/null +++ b/src/components/screens/BackupRecoveryPhraseScreen.tsx @@ -0,0 +1,129 @@ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { RecoveryPhraseGrid } from "components/RecoveryPhraseGrid"; +import { OnboardLayout, OnboardTitle } from "components/layout/OnboardLayout"; +import { CopyPhraseButton } from "components/screens/RecoveryPhraseScreen"; +import { Button } from "components/sds/Button"; +import { Text } from "components/sds/Typography"; +import { + MANAGE_WALLETS_ROUTES, + ManageWalletsStackParamList, +} from "config/routes"; +import { + clearPendingRecoveryPhrase, + getPendingRecoveryPhrase, +} from "ducks/auth"; +import useAppTranslation from "hooks/useAppTranslation"; +import { useSecureClipboard } from "hooks/useSecureClipboard"; +import React, { useEffect, useState } from "react"; +import { View } from "react-native"; +import { analytics } from "services/analytics"; + +type BackupRecoveryPhraseScreenProps = NativeStackScreenProps< + ManageWalletsStackParamList, + typeof MANAGE_WALLETS_ROUTES.BACKUP_RECOVERY_PHRASE_SCREEN +>; + +/** + * Back Up Your New Recovery Phrase: the phrase Create new wallet generated, + * hidden until the user asks to see it, then with Tap to copy, the warning, + * and Create, which goes on to the password step. + * + * The phrase is read from the auth store by its generation id, and the + * store holds it until the password step stores it. Leaving this screen + * backwards drops it, so an abandoned phrase is never stored. + * + * Design: hidden ?node-id=2554-165958, revealed ?node-id=2554-165990. + */ +const BackupRecoveryPhraseScreen: React.FC = ({ + navigation, + route, +}) => { + const { t } = useAppTranslation(); + const { copyToClipboard } = useSecureClipboard(); + const { pendingPhraseId, accountName } = route.params; + const [phrase] = useState(() => getPendingRecoveryPhrase(pendingPhraseId)); + const [isRevealed, setIsRevealed] = useState(false); + + // Mounted for as long as the flow is past this point, so this runs when + // the user backs out, or once the stack resets after a successful create. + // Only this screen's generation, so it can't drop a newer one. + useEffect( + () => () => clearPendingRecoveryPhrase(pendingPhraseId), + [pendingPhraseId], + ); + + useEffect(() => { + // Nothing held for this generation: the session locked, the flow was + // left, or a later generation replaced it. Start again. + if (!phrase) { + navigation.goBack(); + } + }, [navigation, phrase]); + + if (!phrase) { + return null; + } + + const handleCopy = () => { + copyToClipboard(phrase, { hideNotification: true }); + analytics.trackCopyBackupPhrase(); + }; + + const title = ( + {t("backupRecoveryPhraseScreen.title")} + ); + const description = ( + + {t("backupRecoveryPhraseScreen.description")}{" "} + + {t("backupRecoveryPhraseScreen.descriptionEmphasis")} + + + ); + + if (!isRevealed) { + return ( + + + + + + + ); + } + + return ( + + navigation.navigate(MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN, { + kind: "newPhrase", + pendingPhraseId, + accountName, + }) + } + > + + + + + + ); +}; + +export default BackupRecoveryPhraseScreen; diff --git a/src/components/screens/CreateWalletScreen.tsx b/src/components/screens/CreateWalletScreen.tsx new file mode 100644 index 000000000..90373bb84 --- /dev/null +++ b/src/components/screens/CreateWalletScreen.tsx @@ -0,0 +1,264 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { useFocusEffect } from "@react-navigation/native"; +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import AccountNameForm from "components/AccountNameForm"; +import AddWalletOption, { + ADD_WALLET_OPTION_ICON_SIZE, + AddWalletOptionIcon, +} from "components/AddWalletOption"; +import BottomSheet from "components/BottomSheet"; +import ScreenTitle from "components/ScreenTitle"; +import { BaseLayout } from "components/layout/BaseLayout"; +import AccountItemRow from "components/screens/HomeScreen/AccountItemRow"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { DEFAULT_PADDING, DEFAULT_PRESS_DELAY } from "config/constants"; +import { logger } from "config/logger"; +import { + MANAGE_WALLETS_ROUTES, + ManageWalletsStackParamList, +} from "config/routes"; +import { SecretSource } from "config/types"; +import { useAccountsFiatTotalsStore } from "ducks/accountsFiatTotals"; +import { + generatePendingRecoveryPhrase, + useAuthenticationStore, +} from "ducks/auth"; +import { AccountGroupKind, groupAccountsBySource } from "helpers/accountGroups"; +import { getNextDefaultAccountName } from "helpers/accountName"; +import { pxValue } from "helpers/dimensions"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import useGetActiveAccount from "hooks/useGetActiveAccount"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { getSecretSources } from "services/auth/secretSources"; + +type CreateWalletScreenProps = NativeStackScreenProps< + ManageWalletsStackParamList, + typeof MANAGE_WALLETS_ROUTES.CREATE_WALLET_SCREEN +>; + +/** Where the named account goes: an existing phrase, or a new one. */ +type NamingTarget = + | { kind: "existingPhrase"; sourceId: string } + | { kind: "newPhrase" }; + +/** + * Create new wallet: add an account under one of the wallet's recovery + * phrases, or start a new phrase with its first account. Each phrase lists + * its accounts with an Add account row, and New recovery phrase sits last. + * A wallet with no phrase (one started from a secret key) lists only that. + * + * Either way the name is set first, in a sheet. An existing phrase then goes + * to the password step; a new phrase goes to its backup screen first. + * + * Design: ?node-id=2554-164768, the name sheet ?node-id=2554-164937. + */ +const CreateWalletScreen: React.FC = ({ + navigation, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const insets = useSafeAreaInsets(); + const { allAccounts, getAllAccounts, network } = useAuthenticationStore(); + const { account: activeAccount } = useGetActiveAccount(); + const fiatTotals = useAccountsFiatTotalsStore((state) => state.fiatTotals); + const isLoadingFiatTotals = useAccountsFiatTotalsStore( + (state) => state.isLoading, + ); + const fetchAccountsFiatTotals = useAccountsFiatTotalsStore( + (state) => state.fetchAccountsFiatTotals, + ); + const nameSheetRef = useRef(null); + const [target, setTarget] = useState(null); + // Null until the first read, so the phrases aren't drawn and then + // regrouped once the sources arrive. + const [sources, setSources] = useState(null); + + useFocusEffect( + useCallback(() => { + getAllAccounts(); + getSecretSources() + .then((map) => setSources(Object.values(map))) + .catch((error) => { + logger.error( + "CreateWalletScreen", + "Failed to read secret sources", + error, + ); + // No phrases to list; New recovery phrase still works. + setSources((current) => current ?? []); + }); + }, [getAllAccounts]), + ); + + useFocusEffect( + useCallback(() => { + if (allAccounts.length === 0) { + return; + } + // TTL-gated, so returning to the screen doesn't refetch every time. + fetchAccountsFiatTotals({ + publicKeys: allAccounts.map(({ publicKey }) => publicKey), + network, + excludePublicKey: activeAccount?.publicKey, + }); + }, [ + allAccounts, + network, + activeAccount?.publicKey, + fetchAccountsFiatTotals, + ]), + ); + + const phraseGroups = useMemo( + () => + sources + ? groupAccountsBySource({ accounts: allAccounts, sources, t }).filter( + ({ kind }) => kind === AccountGroupKind.PHRASE, + ) + : [], + [allAccounts, sources, t], + ); + + const defaultAccountName = useMemo( + () => + getNextDefaultAccountName( + allAccounts.map(({ name }) => name), + (number) => t("authStore.account", { number }), + ), + [allAccounts, t], + ); + + const startNaming = (next: NamingTarget) => { + setTarget(next); + nameSheetRef.current?.present(); + }; + + const handleNamed = (accountName: string) => { + nameSheetRef.current?.dismiss(); + + if (target?.kind === "newPhrase") { + // Held in the auth store until the password step stores it; the + // backup screen reads it from there by its id, never from params. + const pendingPhraseId = generatePendingRecoveryPhrase(); + navigation.navigate(MANAGE_WALLETS_ROUTES.BACKUP_RECOVERY_PHRASE_SCREEN, { + pendingPhraseId, + accountName, + }); + return; + } + + if (target?.kind === "existingPhrase") { + navigation.navigate(MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN, { + kind: "existingPhrase", + sourceId: target.sourceId, + accountName, + }); + } + }; + + return ( + + + + + {sources && ( + + {phraseGroups.map((group) => ( + + + {group.label} + + {group.accounts.map((account) => ( + + ))} + + startNaming({ + kind: "existingPhrase", + sourceId: group.sourceId!, + }) + } + delayPressIn={DEFAULT_PRESS_DELAY} + accessibilityRole="button" + accessibilityLabel={t("createWalletScreen.addAccountTo", { + label: group.label, + })} + testID="create-wallet-add-account" + > + + + + + {t("createWalletScreen.addAccount")} + + + + ))} + + } + title={t("createWalletScreen.newRecoveryPhrase")} + description={t( + "createWalletScreen.newRecoveryPhraseDescription", + )} + onPress={() => startNaming({ kind: "newPhrase" })} + testID="create-wallet-generate" + /> + + )} + + + + nameSheetRef.current?.dismiss()} + isBottomSheetInput + testID="set-account-name" + /> + } + /> + + ); +}; + +export default CreateWalletScreen; diff --git a/src/components/screens/HomeScreen/AccountItemRow.tsx b/src/components/screens/HomeScreen/AccountItemRow.tsx index b83b4617a..635f3b250 100644 --- a/src/components/screens/HomeScreen/AccountItemRow.tsx +++ b/src/components/screens/HomeScreen/AccountItemRow.tsx @@ -17,7 +17,8 @@ import { interface AccountItemRowProps { account: Account; - handleSelectAccount: (publicKey: string) => Promise; + /** Absent, the row is a plain listing rather than a button. */ + handleSelectAccount?: (publicKey: string) => Promise; isSelected: boolean; isAccountSwitching: boolean; isSwitchingToThisAccount: boolean; @@ -83,8 +84,9 @@ const AccountItemRow: React.FC = ({ .join(", "); const handleSelectAccountPress = useCallback(() => { - handleSelectAccount(account.publicKey); + handleSelectAccount?.(account.publicKey); }, [account.publicKey, handleSelectAccount]); + const isSelectable = !!handleSelectAccount; const renderFiatTotal = () => { if (isTotalLoading) { @@ -112,16 +114,17 @@ const AccountItemRow: React.FC = ({ > diff --git a/src/components/screens/HomeScreen/RenameAccountForm.tsx b/src/components/screens/HomeScreen/RenameAccountForm.tsx index c01edf9f2..21cd87546 100644 --- a/src/components/screens/HomeScreen/RenameAccountForm.tsx +++ b/src/components/screens/HomeScreen/RenameAccountForm.tsx @@ -1,15 +1,7 @@ -import { BottomSheetHeader } from "components/BottomSheetHeader"; -import { Avatar } from "components/sds/Avatar"; -import { Button } from "components/sds/Button"; -import { Input } from "components/sds/Input"; -import { - ACCOUNT_NAME_MAX_LENGTH, - ACCOUNT_NAME_MIN_LENGTH, -} from "config/constants"; +import AccountNameForm from "components/AccountNameForm"; import { Account } from "config/types"; import useAppTranslation from "hooks/useAppTranslation"; -import React, { useEffect, useMemo, useState } from "react"; -import { View } from "react-native"; +import React from "react"; export interface RenameAccountFormProps { /** Account being renamed; null until a rename is requested. */ @@ -22,8 +14,8 @@ export interface RenameAccountFormProps { } /** - * The rename form: avatar, a single autofocused name input and Cancel / Save - * name actions. Edit account shows it in a bottom sheet. + * The rename form: the account name form with Cancel / Save name. Edit + * account shows it in a bottom sheet. */ const RenameAccountForm: React.FC = ({ account, @@ -33,70 +25,20 @@ const RenameAccountForm: React.FC = ({ isBottomSheetInput = false, }) => { const { t } = useAppTranslation(); - const [accountName, setAccountName] = useState(account?.name ?? ""); - - useEffect(() => { - setAccountName(account?.name ?? ""); - }, [account]); - - const isAccountNameValid = useMemo( - () => - accountName.trim().length >= ACCOUNT_NAME_MIN_LENGTH && - accountName.trim().length <= ACCOUNT_NAME_MAX_LENGTH, - [accountName], - ); return ( - - - - - - - - - - - - - - - - - + ); }; diff --git a/src/components/screens/RecoveryPhraseScreen.tsx b/src/components/screens/RecoveryPhraseScreen.tsx index 48efca119..0d01f5947 100644 --- a/src/components/screens/RecoveryPhraseScreen.tsx +++ b/src/components/screens/RecoveryPhraseScreen.tsx @@ -160,7 +160,9 @@ const FadeLayer: React.FC = ({ * back. While "Copied" shows, the button takes no taps and doesn't * highlight, since a tap would do nothing new. */ -const CopyPhraseButton: React.FC<{ onCopy: () => void }> = ({ onCopy }) => { +export const CopyPhraseButton: React.FC<{ onCopy: () => void }> = ({ + onCopy, +}) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); const [isCopied, setIsCopied] = useState(false); diff --git a/src/components/screens/VerifyPasswordScreen.tsx b/src/components/screens/VerifyPasswordScreen.tsx index bcd59d9a7..68f0283b1 100644 --- a/src/components/screens/VerifyPasswordScreen.tsx +++ b/src/components/screens/VerifyPasswordScreen.tsx @@ -14,11 +14,23 @@ type VerifyPasswordScreenProps = NativeStackScreenProps< typeof MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN >; +/** + * The password step of Create new wallet. Confirms with the password, or + * Face ID when it's on, then creates what the route names: an account under + * an existing phrase, or the held new phrase and its first account. + */ const VerifyPasswordScreen: React.FC = ({ navigation, + route, }) => { - const { createAccount, isCreatingAccount, error, clearError } = - useAuthenticationStore(); + const { + createAccount, + addRecoveryPhraseSource, + isCreatingAccount, + error, + clearError, + } = useAuthenticationStore(); + const target = route.params; const [publicKey, setPublicKey] = useState(null); const { t } = useAppTranslation(); @@ -39,15 +51,33 @@ const VerifyPasswordScreen: React.FC = ({ (password: string) => { if (isCreatingAccount) return; - createAccount(password).then(() => { - navigation.reset({ - index: 0, - // @ts-expect-error: This is a valid route. - routes: [{ name: ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK }], - }); - }); + const create = + target.kind === "newPhrase" + ? addRecoveryPhraseSource( + password, + target.pendingPhraseId, + target.accountName, + ) + : createAccount(password, target.sourceId, target.accountName); + + create + .then(() => { + navigation.reset({ + index: 0, + // @ts-expect-error: This is a valid route. + routes: [{ name: ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK }], + }); + }) + // The store has set `error`, which the template shows. + .catch(() => {}); }, - [createAccount, isCreatingAccount, navigation], + [ + addRecoveryPhraseSource, + createAccount, + isCreatingAccount, + navigation, + target, + ], ); return ( diff --git a/src/config/routes.ts b/src/config/routes.ts index 9b4cc6e4a..f2457ea6e 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -102,6 +102,8 @@ export const SETTINGS_ROUTES = { export const MANAGE_WALLETS_ROUTES = { ADD_ANOTHER_WALLET_SCREEN: "AddAnotherWalletScreen", + CREATE_WALLET_SCREEN: "CreateWalletScreen", + BACKUP_RECOVERY_PHRASE_SCREEN: "BackupRecoveryPhraseScreen", VERIFY_PASSWORD_SCREEN: "VerifyPasswordScreen", IMPORT_SECRET_KEY_SCREEN: "ImportSecretKeyScreen", } as const; @@ -230,9 +232,24 @@ export type SettingsStackParamList = { [SETTINGS_ROUTES.REVEAL_SECRET_SCREEN]: RevealSecretParams; }; +/** + * What the password step creates once it's confirmed: an account under an + * existing phrase, or the first account of the phrase the backup step showed. + * The new phrase itself never travels in params; the auth store holds it. + */ +export type CreateWalletTarget = + | { kind: "existingPhrase"; sourceId: string; accountName: string } + | { kind: "newPhrase"; pendingPhraseId: string; accountName: string }; + export type ManageWalletsStackParamList = { [MANAGE_WALLETS_ROUTES.ADD_ANOTHER_WALLET_SCREEN]: undefined; - [MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN]: undefined; + [MANAGE_WALLETS_ROUTES.CREATE_WALLET_SCREEN]: undefined; + [MANAGE_WALLETS_ROUTES.BACKUP_RECOVERY_PHRASE_SCREEN]: { + /** Names the held phrase's generation; the phrase itself stays out of params. */ + pendingPhraseId: string; + accountName: string; + }; + [MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN]: CreateWalletTarget; [MANAGE_WALLETS_ROUTES.IMPORT_SECRET_KEY_SCREEN]: undefined; }; diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 53e84db00..628100127 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -44,6 +44,10 @@ import { usePreferencesStore } from "ducks/preferences"; import { usePricesStore } from "ducks/prices"; import { useRecentProtocolsStore } from "ducks/recentProtocols"; import { useWalletKitStore } from "ducks/walletKit"; +import { + getNextDefaultAccountName, + normalizeAccountName, +} from "helpers/accountName"; import { clearAllWebViewData } from "helpers/browser"; import { deriveKeyFromPassword, @@ -186,6 +190,7 @@ interface ImportWalletParams { * @property {boolean} [shouldRefreshHashKey] - Whether to refresh the hash key (optional) * @property {boolean} [shouldSetActiveAccount] - Whether to set this account as active (optional) * @property {number} [accountNumber] - The account number for derivation (optional) + * @property {string} [accountName] - The name the user chose (optional) */ interface StoreAccountParams { /** @@ -216,6 +221,11 @@ interface StoreAccountParams { shouldRefreshHashKey?: boolean; shouldSetActiveAccount?: boolean; accountNumber?: number; + /** + * The name the user chose. Absent or blank, `accountNumber`'s name, else + * the next default name not already in use. + */ + accountName?: string; } /** @@ -364,9 +374,23 @@ interface AuthActions { /** * Adds an account at its phrase's lowest unused derivation index. `sourceId` * names the phrase; absent, the active account's phrase is used, else the - * pinned source, else the first held. + * pinned source, else the first held. `accountName` names it; absent or + * blank, the next default name. */ - createAccount: (password: string, sourceId?: string) => Promise; + createAccount: ( + password: string, + sourceId?: string, + accountName?: string, + ) => Promise; + /** + * Stores the phrase `generatePendingRecoveryPhrase` is holding as a new + * phrase source and adds its first account, which becomes active. + */ + addRecoveryPhraseSource: ( + password: string, + pendingPhraseId: string, + accountName?: string, + ) => Promise; selectAccount: (publicKey: string) => Promise; selectNetwork: (network: NETWORKS) => Promise; initializeNetwork: () => Promise; @@ -1162,6 +1186,7 @@ const storeAccount = async ({ password, keyPair, accountNumber, + accountName: requestedAccountName, shouldRefreshHashKey = true, shouldSetActiveAccount = true, importedFromSecretKey = false, @@ -1215,9 +1240,16 @@ const storeAccount = async ({ const accountList = accountListRaw ? (JSON.parse(accountListRaw) as Account[]) : []; - const accountName = t("authStore.account", { - number: accountNumber ?? accountList.length + 1, - }); + const formatAccountName = (number: number) => + t("authStore.account", { number }); + const accountName = + normalizeAccountName(requestedAccountName) ?? + (accountNumber !== undefined + ? formatAccountName(accountNumber) + : getNextDefaultAccountName( + accountList.map(({ name }) => name), + formatAccountName, + )); // Listed before it is made active. An active id with no list entry makes // signIn recreate the account without knowing its source, so a write that @@ -1269,6 +1301,67 @@ const deriveKeyPair = (params: DeriveKeypairParams) => { }; }; +/* + A phrase generated for a new source in this wallet, held until the user has + seen it and confirmed with their password. Module state rather than store + state, so it never shows up in a store snapshot or a selector, and the + screens never pass it back: `addRecoveryPhraseSource` reads it from here. + Cleared once stored, when the flow is left, and on lock, log out or wipe. + + Each generation gets an id the screens carry instead of the phrase. The + backup screen keeps the phrase it showed while the held one can be + replaced underneath it (a second generation, e.g. from a double tap), so + confirming must name the generation the user saw. +*/ +let pendingRecoveryPhrase: { mnemonicPhrase: string; id: string } | null = null; +let pendingRecoveryPhraseCount = 0; + +/** + * Generates the phrase for a new source in an existing wallet and holds it + * until `addRecoveryPhraseSource` stores it. Nothing is persisted. A second + * call replaces the held phrase, so backing out and starting over never + * stores the abandoned one. + * + * @returns The id of this generation, for the screens to pass along + */ +export const generatePendingRecoveryPhrase = (): string => { + pendingRecoveryPhraseCount += 1; + pendingRecoveryPhrase = { + mnemonicPhrase: StellarHDWallet.generateMnemonic({ entropyBits: 128 }), + // Unique within the process, which is all it has to be: the phrase never + // outlives it. + id: `pending-phrase-${pendingRecoveryPhraseCount}`, + }; + return pendingRecoveryPhrase.id; +}; + +/** + * The held phrase, if it's still the one generation `pendingPhraseId` made. + * + * @param {string} pendingPhraseId - The id `generatePendingRecoveryPhrase` returned + */ +export const getPendingRecoveryPhrase = ( + pendingPhraseId: string, +): string | null => + pendingPhraseId && pendingRecoveryPhrase?.id === pendingPhraseId + ? pendingRecoveryPhrase.mnemonicPhrase + : null; + +/** + * Drops the held phrase without storing it. With an id, only if it's still + * that generation, so a screen going away can't drop a newer one. + * + * @param {string} [pendingPhraseId] - Limits the clear to that generation (optional) + */ +export const clearPendingRecoveryPhrase = (pendingPhraseId?: string): void => { + if ( + pendingPhraseId === undefined || + pendingRecoveryPhrase?.id === pendingPhraseId + ) { + pendingRecoveryPhrase = null; + } +}; + /** * Clears all data from the key manager, temporary store (sensitive), * and non-sensitive data @@ -1278,6 +1371,7 @@ const deriveKeyPair = (params: DeriveKeypairParams) => { const clearAllData = async (): Promise => { clearDerivedKeyCache(); clearCachedWalletSources(); + clearPendingRecoveryPhrase(); const allKeys = await keyManager.loadAllKeyIds(); @@ -2614,12 +2708,14 @@ const hasAccountInAccountList = ( * @param {string} password - The user's password, verified against the active keystore first * @param {AuthStatus} authStatus - Current auth status, gating the session read * @param {string} [requestedSourceId] - The phrase source to derive from (optional) + * @param {string} [accountName] - The new account's name; absent or blank, the next default name (optional) * @returns {Promise} */ const createAccount = async ( password: string, authStatus: AuthStatus, requestedSourceId?: string, + accountName?: string, ): Promise => { // Verifies the password before anything is derived: the new keystore is // encrypted with it, so a wrong one must fail here, not produce a keystore @@ -2713,10 +2809,78 @@ const createAccount = async ( derivationIndex, password, keyPair, + accountName, shouldRefreshHashKey: false, }); }; +/** + * Stores the held phrase as a new phrase source and derives its first + * account, at index 0, which becomes active. The wallet it joins may hold any + * mix of sources, or no phrase at all (a secret-key wallet), so the password + * is checked against the active keystore, as an import checks it. + * + * Unlike sign-up, this adds to a live wallet: it never clears data, and the + * new phrase joins the phrases the session already holds. + * + * @param {string} password - The user's password, verified against the active keystore first + * @param {AuthStatus} authStatus - Current auth status, gating the session read + * @param {string} pendingPhraseId - The generation the user backed up; refused if another replaced it + * @param {string} [accountName] - The new account's name; absent or blank, the next default name (optional) + * @returns {Promise} + */ +const addRecoveryPhraseSource = async ( + password: string, + authStatus: AuthStatus, + pendingPhraseId: string, + accountName?: string, +): Promise => { + // A replaced generation reads as missing: the held phrase isn't the one + // the user saw. + const mnemonicPhrase = getPendingRecoveryPhrase(pendingPhraseId); + if (!mnemonicPhrase) { + throw new Error(t("authStore.error.pendingRecoveryPhraseMissing")); + } + + // The new keystore is encrypted with the password, so a wrong one must + // fail here, not produce a keystore the wallet can never open. + await getKeyFromKeyManager(password); + + const temporaryStore = await getTemporaryStore(authStatus); + if (!temporaryStore) { + throw new Error(t("authStore.error.temporaryStoreNotFound")); + } + + const sourceId = await getPhraseSourceId(mnemonicPhrase); + const keyPair = deriveKeyPair({ mnemonicPhrase }); + + // A generated phrase colliding is not a real case, but these checks are + // what keep two sources or two accounts from sharing an id or an address. + if ((await getSecretSources())[sourceId]) { + throw new Error(t("authStore.error.recoveryPhraseAlreadyHeld")); + } + if (hasAccountInAccountList(await getAllAccounts(), keyPair)) { + throw new Error(t("authStore.error.accountAlreadyExists")); + } + + await storeAccount({ + mnemonicPhrase, + phraseSourceId: sourceId, + sourceId, + derivationIndex: 0, + password, + keyPair, + accountName, + shouldRefreshHashKey: false, + }); + await addSecretSource({ + id: sourceId, + type: SecretSourceType.PHRASE, + }); + + clearPendingRecoveryPhrase(); +}; + /** * Imports a secret key and creates a new account * @@ -2832,6 +2996,8 @@ export const getUserFacingError = ( t("authStore.error.noActiveAccount"), t("authStore.error.noRecoveryPhrase"), t("authStore.error.unknownRecoveryPhrase"), + t("authStore.error.pendingRecoveryPhraseMissing"), + t("authStore.error.recoveryPhraseAlreadyHeld"), t("authStore.error.authenticationExpired"), t("authStore.error.failedToReEncryptData"), t("authStore.error.failedToDecryptData"), @@ -2931,6 +3097,7 @@ export const useAuthenticationStore = create()((set, get) => ({ */ logout: (shouldWipeAllData = false) => { set((state) => ({ ...state, isLoading: true, error: null })); + clearPendingRecoveryPhrase(); // We'll use setTimeout to handle the async operations // This avoids the issue with void return expectations from zustand @@ -3061,6 +3228,8 @@ export const useAuthenticationStore = create()((set, get) => ({ */ softLock: async (options?: { suppressBiometricPrompt?: boolean }) => { Keyboard.dismiss(); + // A phrase not yet confirmed doesn't outlive the session that made it. + clearPendingRecoveryPhrase(); // Atomic update: RootNavigator must never see LOCKED && !isSoftLocked // (it would unmount the preserved tree). Clear account so the decrypted @@ -3855,11 +4024,15 @@ export const useAuthenticationStore = create()((set, get) => ({ } }, - createAccount: async (password: string, sourceId?: string) => { + createAccount: async ( + password: string, + sourceId?: string, + accountName?: string, + ) => { set({ isCreatingAccount: true, error: null }); try { - await createAccount(password, get().authStatus, sourceId); + await createAccount(password, get().authStatus, sourceId, accountName); // The new account becomes active: drop the previous account's data so // nothing (e.g. priced balances) carries over into its first render. @@ -3897,6 +4070,52 @@ export const useAuthenticationStore = create()((set, get) => ({ } }, + addRecoveryPhraseSource: async ( + password: string, + pendingPhraseId: string, + accountName?: string, + ) => { + set({ isCreatingAccount: true, error: null }); + + try { + await addRecoveryPhraseSource( + password, + get().authStatus, + pendingPhraseId, + accountName, + ); + + // The new account becomes active, as on createAccount. + clearAccountData({ keepAccountsFiatTotals: true }); + + await Promise.all([get().getAllAccounts(), get().fetchActiveAccount()]); + + analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_ADD_ACCOUNT, { + number_of_accounts: get().allAccounts.length, + source: "new_phrase", + }); + + // The new phrase is a source of its own. + get().linkSources(); + + set({ isCreatingAccount: false, error: null }); + } catch (error) { + logger.error( + "useAuthenticationStore.addRecoveryPhraseSource", + "Failed to add recovery phrase", + error, + ); + set({ + error: getUserFacingError( + error, + "authStore.error.failedToAddRecoveryPhrase", + ), + isCreatingAccount: false, + }); + throw error; + } + }, + selectAccount: async (publicKey: string) => { set({ isSwitchingAccount: true, error: null }); try { diff --git a/src/helpers/accountName.ts b/src/helpers/accountName.ts new file mode 100644 index 000000000..987cc47d9 --- /dev/null +++ b/src/helpers/accountName.ts @@ -0,0 +1,40 @@ +import { ACCOUNT_NAME_MAX_LENGTH } from "config/constants"; + +/** + * The default name for a new account: `formatName(N)`, where N starts one + * past the number of accounts and moves up past any name already in use. + * Counting from the account total keeps names sequential across the wallet; + * skipping taken names keeps a renamed or reordered wallet from getting a + * duplicate. + * + * @param existingNames - The names of every account in the wallet + * @param formatName - Builds the name for a number, e.g. "Account 3" + */ +export const getNextDefaultAccountName = ( + existingNames: string[], + formatName: (number: number) => string, +): string => { + const taken = new Set(existingNames); + const first = existingNames.length + 1; + + // N names can take at most N of the next N + 1 numbers, so one of them is + // free. Bounded rather than open-ended, so a format that ignores the + // number can't loop forever. + for (let offset = 0; offset <= existingNames.length; offset += 1) { + const name = formatName(first + offset); + if (!taken.has(name)) { + return name; + } + } + + return formatName(first); +}; + +/** + * A caller-supplied name, trimmed and capped at `ACCOUNT_NAME_MAX_LENGTH`, or + * null when nothing usable is left, so the caller falls back to the default. + */ +export const normalizeAccountName = (name?: string): string | null => { + const trimmed = (name ?? "").trim().slice(0, ACCOUNT_NAME_MAX_LENGTH); + return trimmed || null; +}; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 06b63a38c..c8bcac83d 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -511,7 +511,10 @@ "sessionExpiredTitle": "Session expired", "sessionExpiredMessage": "Your session is no longer valid. Please sign in again.", "disableBiometricsFailedTitle": "Couldn't disable biometrics", - "disableBiometricsFailedMessage": "We couldn't verify your identity. Please try again." + "disableBiometricsFailedMessage": "We couldn't verify your identity. Please try again.", + "pendingRecoveryPhraseMissing": "The new recovery phrase is no longer available. Start again.", + "recoveryPhraseAlreadyHeld": "This recovery phrase is already in your wallet", + "failedToAddRecoveryPhrase": "Failed to add the recovery phrase. Please try again." }, "account": "Account {{number}}", "faceId": { @@ -900,14 +903,32 @@ "checkMemoRequirements": "Check the destination account memo requirements and include it in the transaction." }, "addAnotherWalletScreen": { - "title": "Add wallet", + "title": "Add account", + "subtitle": "Choose how to add a wallet.", "actions": { - "createNewWallet": "Create a new wallet", - "createNewWalletDescription": "Create a wallet from your seed phrase", - "importSecretKey": "Import a Stellar secret key", - "importSecretKeyDescription": "Add a wallet using a secret key" + "createNewWallet": "Create new wallet", + "createNewWalletDescription": "Using an existing or new recovery phrase", + "importSecretKey": "Import Secret Key", + "importSecretKeyDescription": "Import a wallet using its secret key." } }, + "createWalletScreen": { + "title": "Create new wallet", + "subtitle": "Create with a new or existing recovery phrase", + "addAccount": "Add account", + "addAccountTo": "Add account to {{label}}", + "newRecoveryPhrase": "New recovery phrase", + "newRecoveryPhraseDescription": "Create a wallet with a new recovery phrase", + "setAccountName": "Set account name", + "create": "Create", + "next": "Next" + }, + "backupRecoveryPhraseScreen": { + "title": "Back Up Your New Recovery Phrase", + "description": "Your recovery phrase is the only way to restore your new wallet.", + "descriptionEmphasis": "Keep it safe.", + "create": "Create" + }, "verifyPasswordScreen": { "title": "Verify Password", "verifyPasswordTemplateTitle": "Enter your password", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 844beea3c..83c3ad09f 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -477,7 +477,10 @@ "sessionExpiredTitle": "Sessão expirada", "sessionExpiredMessage": "Sua sessão não é mais válida. Por favor, entre novamente.", "disableBiometricsFailedTitle": "Não foi possível desativar a biometria", - "disableBiometricsFailedMessage": "Não conseguimos verificar sua identidade. Por favor, tente novamente." + "disableBiometricsFailedMessage": "Não conseguimos verificar sua identidade. Por favor, tente novamente.", + "pendingRecoveryPhraseMissing": "A nova frase de recuperação não está mais disponível. Comece novamente.", + "recoveryPhraseAlreadyHeld": "Esta frase de recuperação já está na sua carteira", + "failedToAddRecoveryPhrase": "Falha ao adicionar a frase de recuperação. Por favor, tente novamente." }, "account": "Conta {{number}}", "faceId": { @@ -866,14 +869,32 @@ "checkMemoRequirements": "Verifique os requisitos de memo da conta de destino e inclua-o na transação." }, "addAnotherWalletScreen": { - "title": "Adicionar carteira", + "title": "Adicionar conta", + "subtitle": "Escolha como adicionar uma carteira.", "actions": { - "createNewWallet": "Criar uma nova carteira", - "createNewWalletDescription": "Criar uma carteira a partir da sua frase de recuperação", - "importSecretKey": "Importar uma chave secreta Stellar", - "importSecretKeyDescription": "Adicionar uma carteira usando uma chave secreta" + "createNewWallet": "Criar nova carteira", + "createNewWalletDescription": "Usando uma frase de recuperação existente ou nova", + "importSecretKey": "Importar chave secreta", + "importSecretKeyDescription": "Importe uma carteira usando a chave secreta dela." } }, + "createWalletScreen": { + "title": "Criar nova carteira", + "subtitle": "Crie com uma frase de recuperação nova ou existente", + "addAccount": "Adicionar conta", + "addAccountTo": "Adicionar conta a {{label}}", + "newRecoveryPhrase": "Nova frase de recuperação", + "newRecoveryPhraseDescription": "Crie uma carteira com uma nova frase de recuperação", + "setAccountName": "Definir nome da conta", + "create": "Criar", + "next": "Próximo" + }, + "backupRecoveryPhraseScreen": { + "title": "Faça backup da sua nova frase de recuperação", + "description": "Sua frase de recuperação é a única forma de restaurar sua nova carteira.", + "descriptionEmphasis": "Guarde-a em segurança.", + "create": "Criar" + }, "verifyPasswordScreen": { "title": "Verificar Senha", "verifyPasswordTemplateTitle": "Digite sua senha", diff --git a/src/navigators/ManageWalletsNavigator.tsx b/src/navigators/ManageWalletsNavigator.tsx index 65f3238c3..fc2b6f17e 100644 --- a/src/navigators/ManageWalletsNavigator.tsx +++ b/src/navigators/ManageWalletsNavigator.tsx @@ -3,6 +3,8 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { CustomHeaderButton } from "components/layout/CustomHeaderButton"; import CustomNavigationHeader from "components/layout/CustomNavigationHeader"; import AddAnotherWalletScreen from "components/screens/AddAnotherWallet"; +import BackupRecoveryPhraseScreen from "components/screens/BackupRecoveryPhraseScreen"; +import CreateWalletScreen from "components/screens/CreateWalletScreen"; import ImportSecretKeyScreen from "components/screens/ImportSecretKeyScreen"; import VerifyPasswordScreen from "components/screens/VerifyPasswordScreen"; import { @@ -33,10 +35,32 @@ export const ManageWalletsStackNavigator = () => { name={MANAGE_WALLETS_ROUTES.ADD_ANOTHER_WALLET_SCREEN} component={AddAnotherWalletScreen} options={{ - headerTitle: t("addAnotherWalletScreen.title"), + // The screen sets its own large title, under a close-only header. + headerTitle: "", headerLeft: () => , }} /> + + ({ + headerTitle: "", + // Leaves the whole add-wallet flow, as the design's X does. + headerRight: () => ( + navigation.getParent()?.goBack()} + testID="backup-recovery-phrase-close" + /> + ), + })} + /> Date: Thu, 12 Nov 2026 09:33:27 -0700 Subject: [PATCH 05/14] fix(accounts): serialize account-mutating auth actions Port of stellar/freighter#3086. Sign-up, import, sign-in, add, rename, switch and the wipe's storage clear now run one at a time behind a promise-chain lock. A lazily loaded private key is written to a fresh read of the temporary store under the lock. A switch drops its account if a soft-lock lands mid-switch. Funded-account lookups during a recovery-phrase import time out after 5s. --- __tests__/ducks/auth.test.ts | 202 ++++++++++++++++++ __tests__/helpers/accountMutationLock.test.ts | 46 ++++ src/ducks/auth.ts | 183 +++++++++++----- src/helpers/accountMutationLock.ts | 36 ++++ 4 files changed, 416 insertions(+), 51 deletions(-) create mode 100644 __tests__/helpers/accountMutationLock.test.ts create mode 100644 src/helpers/accountMutationLock.ts diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 9f74442cd..8c544bbbf 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -4952,6 +4952,208 @@ describe("auth duck", () => { expect(storedIndices()).toEqual({ [mockPhraseSourceId]: [0, 1, 2] }); }); + describe("overlapping account changes", () => { + const flush = () => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + it("gives two overlapping adds on one phrase different indices", async () => { + const { result } = mockAddAccountSession({ + accounts: [phraseAccount(0)], + indices: { [mockPhraseSourceId]: [0] }, + }); + let stored = 0; + sharedKeyManager.storeKey.mockImplementation(() => { + stored += 1; + return Promise.resolve({ id: `new-account-${stored}` }); + }); + + await act(async () => { + await Promise.all([ + result.current.createAccount(mockPassword), + result.current.createAccount(mockPassword), + ]); + }); + + expect(result.current.error).toBeNull(); + expect(storedPublicKeys()).toEqual([ + publicKeyAt(mockMnemonicPhrase, 1), + publicKeyAt(mockMnemonicPhrase, 2), + ]); + expect(storedIndices()).toEqual({ + [mockPhraseSourceId]: [0, 1, 2], + }); + expect(storedAccounts().map(({ id }) => id)).toEqual([ + "account-0", + "new-account-1", + "new-account-2", + ]); + }); + + it("lets an add in flight finish before a wipe clears the wallet", async () => { + const { result } = mockAddAccountSession({ + accounts: [phraseAccount(0)], + indices: { [mockPhraseSourceId]: [0] }, + }); + wipeNonSensitiveData(); + let releaseKeystore: (() => void) | undefined; + sharedKeyManager.storeKey.mockImplementation( + () => + new Promise((resolve) => { + releaseKeystore = () => resolve({ id: "new-account" }); + }), + ); + act(() => { + useAuthenticationStore.setState({ + logout: originalStoreMethods.logout, + }); + }); + + await act(async () => { + const adding = result.current + .createAccount(mockPassword) + .catch(() => undefined); + await flush(); + expect(releaseKeystore).toBeDefined(); + + result.current.logout(true); + await flush(); + await flush(); + + releaseKeystore?.(); + await adding; + await flush(); + await flush(); + }); + + expect(clearNonSensitiveData).toHaveBeenCalled(); + expect(dataStorageMemory).not.toHaveProperty( + STORAGE_KEYS.ACCOUNT_LIST, + ); + expect(dataStorageMemory).not.toHaveProperty( + STORAGE_KEYS.DERIVATION_INDICES, + ); + }); + + it("drops a switch's account once a soft-lock lands during it", async () => { + const { result } = mockAddAccountSession({ + accounts: [phraseAccount(0), phraseAccount(1)], + indices: { [mockPhraseSourceId]: [0, 1] }, + }); + ( + useAuthenticationStore.getState().getAuthStatus as jest.Mock + ).mockResolvedValue(AUTH_STATUS.AUTHENTICATED); + act(() => { + useAuthenticationStore.setState({ + selectAccount: originalStoreMethods.selectAccount, + softLock: originalStoreMethods.softLock, + }); + }); + + await act(async () => { + const switching = result.current.selectAccount( + publicKeyAt(mockMnemonicPhrase, 1), + ); + await result.current.softLock(); + await switching; + }); + + expect(dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID]).toBe( + "account-1", + ); + expect(result.current.isSoftLocked).toBe(true); + expect(result.current.account).toBeNull(); + expect(result.current.isSwitchingAccount).toBe(false); + }); + + it("caches a lazily loaded key onto the session as it stands, not as first read", async () => { + mockAddAccountSession({ accounts: [phraseAccount(0)] }); + const mnemonics = { [mockPhraseSourceId]: mockMnemonicPhrase }; + // The first read is getActiveAccount's; by the second, an add has + // put its key into the session. + (decryptDataWithDerivedKey as jest.Mock) + .mockReturnValueOnce(JSON.stringify({ privateKeys: {}, mnemonics })) + .mockReturnValue( + JSON.stringify({ + privateKeys: { "added-account": "S:added" }, + mnemonics, + }), + ); + ( + useAuthenticationStore.getState().getAuthStatus as jest.Mock + ).mockResolvedValue(AUTH_STATUS.AUTHENTICATED); + act(() => { + useAuthenticationStore.setState({ + fetchActiveAccount: originalStoreMethods.fetchActiveAccount, + }); + }); + + await act(async () => { + await useAuthenticationStore.getState().fetchActiveAccount(); + }); + + const written = writtenTemporaryStores(); + expect(written[written.length - 1].privateKeys).toEqual({ + "added-account": "S:added", + "account-0": `S:${mockMnemonicPhrase}:0`, + }); + }); + + it("cuts off a funded lookup that stalls, so the import finishes", async () => { + jest.useFakeTimers(); + try { + mockIndexedWallet(); + mockAuthenticatedSession( + JSON.stringify({ privateKeys: {}, mnemonics: {} }), + ); + delete dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST]; + let stored = 0; + sharedKeyManager.storeKey.mockImplementation(() => { + stored += 1; + return Promise.resolve({ id: `recovered-${stored}` }); + }); + // Index 1 is funded but its lookup never answers; the rest are + // unfunded. + (getAccount as jest.Mock).mockImplementation((publicKey: string) => + publicKey === publicKeyAt(mockMnemonicPhrase, 1) + ? new Promise(() => {}) + : Promise.resolve(null), + ); + const { result } = renderHook(() => useAuthenticationStore()); + act(() => { + useAuthenticationStore.setState({ + importWallet: originalStoreMethods.importWallet, + }); + }); + + let imported: boolean | undefined; + await act(async () => { + const importing = result.current + .importWallet({ + mnemonicPhrase: mockMnemonicPhrase, + password: mockPassword, + }) + .then((value) => { + imported = value; + }); + // Short of the cutoff, the import still waits on the lookup. + await jest.advanceTimersByTimeAsync(4_999); + expect(imported).toBeUndefined(); + await jest.advanceTimersByTimeAsync(1); + await importing; + }); + + expect(imported).toBe(true); + expect(storedAccounts().map(({ publicKey }) => publicKey)).toEqual([ + publicKeyAt(mockMnemonicPhrase, 0), + ]); + } finally { + jest.useRealTimers(); + } + }); + }); + describe("account names", () => { it("names the new account as the caller asks, trimmed", async () => { const { result } = mockAddAccountSession({ diff --git a/__tests__/helpers/accountMutationLock.test.ts b/__tests__/helpers/accountMutationLock.test.ts new file mode 100644 index 000000000..34b72d36f --- /dev/null +++ b/__tests__/helpers/accountMutationLock.test.ts @@ -0,0 +1,46 @@ +import { withAccountMutationLock } from "helpers/accountMutationLock"; + +const deferred = () => { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +describe("withAccountMutationLock", () => { + it("runs a second holder only after the first settles", async () => { + const first = deferred(); + const events: string[] = []; + + const a = withAccountMutationLock(async () => { + events.push("a:start"); + await first.promise; + events.push("a:end"); + return "a"; + }); + const b = withAccountMutationLock(() => { + events.push("b:start"); + return Promise.resolve("b"); + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["a:start"]); + + first.resolve(); + + await expect(Promise.all([a, b])).resolves.toEqual(["a", "b"]); + expect(events).toEqual(["a:start", "a:end", "b:start"]); + }); + + it("releases the lock when a holder rejects, and passes the rejection on", async () => { + const failing = withAccountMutationLock(() => + Promise.reject(new Error("boom")), + ); + const next = withAccountMutationLock(() => Promise.resolve("next")); + + await expect(failing).rejects.toThrow("boom"); + await expect(next).resolves.toBe("next"); + }); +}); diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 628100127..19971a77a 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -44,6 +44,7 @@ import { usePreferencesStore } from "ducks/preferences"; import { usePricesStore } from "ducks/prices"; import { useRecentProtocolsStore } from "ducks/recentProtocols"; import { useWalletKitStore } from "ducks/walletKit"; +import { withAccountMutationLock } from "helpers/accountMutationLock"; import { getNextDefaultAccountName, normalizeAccountName, @@ -1437,6 +1438,21 @@ const getKeyFromKeyManager = async ( return result; }; +const FUNDED_LOOKUP_TIMEOUT_MS = 5_000; + +/** + * Resolves to null once the lookup has taken `FUNDED_LOOKUP_TIMEOUT_MS`. + * Horizon's loadAccount takes no abort signal, so the request itself runs on; + * only the wait for it ends. + */ +const withFundedLookupTimeout = (lookup: Promise): Promise => { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), FUNDED_LOOKUP_TIMEOUT_MS); + }); + return Promise.race([lookup, timeout]).finally(() => clearTimeout(timer)); +}; + /** * Derive keypairs from the mnemonic phrase and verify if they exist on the network. * If they do, create accounts for them. @@ -1497,9 +1513,12 @@ const verifyAndCreateExistingAccountsOnNetwork = async ( return; } - // Only make network calls for accounts we don't have locally + // Only make network calls for accounts we don't have locally. Bounded: + // this runs under the account-mutation lock, and a stalled lookup would + // hold off a wipe. A lookup that times out reads as unfunded, as a failed + // one does. const promises = accountsToVerify.map((keyPair) => - getAccount(keyPair.publicKey, NETWORKS.PUBLIC), + withFundedLookupTimeout(getAccount(keyPair.publicKey, NETWORKS.PUBLIC)), ); const result = await Promise.all(promises); @@ -2534,6 +2553,42 @@ const loadPrivateKeyFromKeyManager = async ( } }; +/** + * Writes a lazily loaded private key into the temporary store, under the + * account-mutation lock and onto a fresh read of the store. The copy + * getActiveAccount read can predate an add or import that finished since, + * and writing it back would drop that account's key or phrase. + * + * Takes the lock, so it must not run inside a holder: getActiveAccount runs + * only from store actions, after their locked part. + * + * @param {AuthStatus} authStatus - Current auth status, gating the session read + * @param {string} accountId - The account the key belongs to + * @param {string} privateKey - The key to cache + * @returns {Promise} + */ +const cachePrivateKey = ( + authStatus: AuthStatus, + accountId: string, + privateKey: string, +): Promise => + withAccountMutationLock(async () => { + const [temporaryStore, hashKey] = await Promise.all([ + getTemporaryStore(authStatus), + getHashKey(), + ]); + // Gone since the read: a wipe got there first. + if (!temporaryStore || !hashKey) { + return; + } + await updateTemporaryStoreWithPrivateKey( + temporaryStore, + accountId, + privateKey, + hashKey, + ); + }); + /** * Gets the active account data by combining temporary store sensitive data with account list information * @@ -2608,12 +2663,7 @@ const getActiveAccount = async ( if (derivedKey) { privateKey = derivedKey; - await updateTemporaryStoreWithPrivateKey( - temporaryStore, - activeAccountId, - privateKey, - hashKey, - ); + await cachePrivateKey(authStatus, activeAccountId, privateKey); } // If still not found and account was imported from secret key, try loading from key manager @@ -2623,12 +2673,7 @@ const getActiveAccount = async ( if (loadedKey) { privateKey = loadedKey; - await updateTemporaryStoreWithPrivateKey( - temporaryStore, - activeAccountId, - privateKey, - hashKey, - ); + await cachePrivateKey(authStatus, activeAccountId, privateKey); } } } catch (e) { @@ -3188,19 +3233,30 @@ export const useAuthenticationStore = create()((set, get) => ({ // Clear all recent protocols data useRecentProtocolsStore.getState().clearRecentProtocols(); - await clearTemporaryData(); - await clearNonSensitiveData(); - await clearLinkStatus(); - await dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST); - - // Reset auto-lock so the next wallet on this device doesn't - // inherit the previous user's timer. The awaited secure-mirror - // write (source of truth for getAuthStatus / generateHashKey) - // means an interrupted wipe can't leave a weaker policy — e.g. - // NONE's never-expire — behind for the next wallet. - await resetAutoLockForNewWallet(); - - await clearBiometricsData(); + // Under the lock, so an add, import or switch in flight finishes + // first and none of them writes its account back into the wiped + // wallet. The WalletConnect and WebView cleanup above stays + // outside: it waits on the network and holds no account state. + await withAccountMutationLock(async () => { + // A holder that finished since the top of the wipe may have + // cached its session key or sources again. + clearDerivedKeyCache(); + clearCachedWalletSources(); + + await clearTemporaryData(); + await clearNonSensitiveData(); + await clearLinkStatus(); + await dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST); + + // Reset auto-lock so the next wallet on this device doesn't + // inherit the previous user's timer. The awaited secure-mirror + // write (source of truth for getAuthStatus / generateHashKey) + // means an interrupted wipe can't leave a weaker policy — e.g. + // NONE's never-expire — behind for the next wallet. + await resetAutoLockForNewWallet(); + + await clearBiometricsData(); + }); set({ isLoading: false }); } @@ -3277,8 +3333,10 @@ export const useAuthenticationStore = create()((set, get) => ({ try { // Fresh wallet: reset auto-lock to the default before the hash key is // generated so it doesn't inherit a previous wallet's timer. - await resetAutoLockForNewWallet(); - await signUp(params); + await withAccountMutationLock(async () => { + await resetAutoLockForNewWallet(); + await signUp(params); + }); // Mirror the extension's onboarding.password_created (createAccount.fulfilled) // so the create-password funnel has a success side on mobile (was fail-only). analytics.track(AnalyticsEvent.CREATE_PASSWORD_SUCCESS); @@ -3331,18 +3389,26 @@ export const useAuthenticationStore = create()((set, get) => ({ const currentAuthStatus = get().authStatus; const shouldCreateTempStore = currentAuthStatus !== AUTH_STATUS.LOCKED; - const { phraseSigners } = await signIn({ - ...params, - shouldCreateTempStore, - }); + // Sign-in rebuilds or merges into the temporary store and can append + // and attribute accounts, so it holds the lock. The account load below + // runs after it, outside. + const { phraseSigners } = await withAccountMutationLock(async () => { + const signedIn = await signIn({ + ...params, + shouldCreateTempStore, + }); - // Clear the persisted LOCKED marker and stale backgrounded-at timestamp - // before flipping to AUTHENTICATED — awaited so the auto-lock funnel - // can't observe the old values and re-lock the fresh session. - await Promise.all([ - secureDataStorage.remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS), - clearBackgroundedAt(), - ]); + // Clear the persisted LOCKED marker and stale backgrounded-at + // timestamp before flipping to AUTHENTICATED — awaited so the + // auto-lock funnel can't observe the old values and re-lock the + // fresh session. + await Promise.all([ + secureDataStorage.remove(SENSITIVE_STORAGE_KEYS.AUTH_STATUS), + clearBackgroundedAt(), + ]); + + return signedIn; + }); // Password verified — unblock navigation immediately. // getActiveAccount is slow (derivePrivateKeyFromMnemonic) so we run it @@ -3776,8 +3842,10 @@ export const useAuthenticationStore = create()((set, get) => ({ try { // Re-import replaces the wallet: reset auto-lock to the default before // the hash key is generated so it doesn't inherit the previous timer. - await resetAutoLockForNewWallet(); - await importWallet(params); + await withAccountMutationLock(async () => { + await resetAutoLockForNewWallet(); + await importWallet(params); + }); set({ ...initialState, navigationRef: get().navigationRef, @@ -3979,7 +4047,7 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isRenamingAccount: true }); try { - await renameAccount(params); + await withAccountMutationLock(() => renameAccount(params)); await Promise.all([get().fetchActiveAccount(), get().getAllAccounts()]); set({ isRenamingAccount: false }); } catch (error) { @@ -4032,7 +4100,9 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isCreatingAccount: true, error: null }); try { - await createAccount(password, get().authStatus, sourceId, accountName); + await withAccountMutationLock(() => + createAccount(password, get().authStatus, sourceId, accountName), + ); // The new account becomes active: drop the previous account's data so // nothing (e.g. priced balances) carries over into its first render. @@ -4078,11 +4148,13 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isCreatingAccount: true, error: null }); try { - await addRecoveryPhraseSource( - password, - get().authStatus, - pendingPhraseId, - accountName, + await withAccountMutationLock(() => + addRecoveryPhraseSource( + password, + get().authStatus, + pendingPhraseId, + accountName, + ), ); // The new account becomes active, as on createAccount. @@ -4135,8 +4207,15 @@ export const useAuthenticationStore = create()((set, get) => ({ // dropping them would flash $0.00 rows in the sheet mid-switch (the // switch itself force-refreshes them in the background). clearAccountData({ keepAccountsFiatTotals: true }); - await selectAccount(publicKey); + await withAccountMutationLock(() => selectAccount(publicKey)); const activeAccount = await getActiveAccount(authStatus); + // A soft-lock may have engaged while the switch waited on the lock or + // loaded the key (softLock clears account to null). Don't put the + // private key back into a locked store; the next unlock reloads it. + if (get().isSoftLocked) { + set({ isSwitchingAccount: false }); + return; + } set({ account: activeAccount, isSwitchingAccount: false }); } catch (error) { logger.error( @@ -4180,7 +4259,9 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isLoading: true, error: null }); try { - await importSecretKeyLocal(params, get().authStatus); + await withAccountMutationLock(() => + importSecretKeyLocal(params, get().authStatus), + ); // The imported account becomes active: drop the previous account's // data so nothing (e.g. priced balances) carries over into its first diff --git a/src/helpers/accountMutationLock.ts b/src/helpers/accountMutationLock.ts new file mode 100644 index 000000000..cf148d520 --- /dev/null +++ b/src/helpers/accountMutationLock.ts @@ -0,0 +1,36 @@ +/* + Serializes the auth flows that change the wallet's accounts, sources or + session. Each one reads the account list, the derivation indices, the + source records and the encrypted temporary store, and writes them back + whole. Two of them overlapping (a double tap, an add while the switcher + loads the active key, a wipe while an import is still storing) let the + later writer drop the earlier one's account, name, index or phrase, or + write an account back into a wallet that was just wiped. + + Reads stay off the lock, so a slow Scrypt import doesn't stall them. + + The wipe waits on this lock too, so a holder must not await a network call + without a timeout. Best-effort requests (source linking, analytics) run + detached. + + Not reentrant: a holder that calls something that takes the lock again + waits on itself forever. Take it at the store action, around the module + function, and keep the reads that follow outside. +*/ + +let tail: Promise = Promise.resolve(); + +/** + * Runs `fn` once every earlier holder has settled. A rejection still releases + * the lock, and reaches the caller. + * + * @param {() => Promise} fn - The work to run while holding the lock + * @returns {Promise} What `fn` resolves to + */ +export const withAccountMutationLock = ( + fn: () => Promise, +): Promise => { + const run = tail.then(fn); + tail = run.catch(() => undefined); + return run; +}; From 708246a2573e95f2c9cc87050ad5ea188f27f419 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 12 Nov 2026 10:02:35 -0700 Subject: [PATCH 06/14] feat(accounts): import an additional recovery phrase without wiping the wallet Port of stellar/freighter#3084. Add wallet now offers Import Recovery Phrase, which adds a typed 12- or 24-word phrase as a new source with index 0 and any funded indices up to 49 on mainnet. Index 0 becomes active and nothing already in the wallet changes. A held phrase, an address already in the wallet or a wrong password is refused before anything is written. The funded-account lookups run before the account-mutation lock is taken, and the checks and writes run under it. If no account gets stored, the source and its indices are removed again. --- .../screens/AddAnotherWallet.test.tsx | 6 +- .../ImportRecoveryPhraseScreen.test.tsx | 122 +++++++ .../screens/VerifyPasswordScreen.test.tsx | 30 ++ __tests__/ducks/auth.test.ts | 315 ++++++++++++++++++ __tests__/helpers/recoveryPhrase.test.ts | 34 ++ __tests__/services/auth/secretSources.test.ts | 44 +++ src/components/screens/AddAnotherWallet.tsx | 26 +- .../screens/ImportRecoveryPhraseScreen.tsx | 117 +++++++ .../screens/VerifyPasswordScreen.tsx | 31 +- src/config/routes.ts | 10 +- src/ducks/auth.ts | 306 ++++++++++++++++- src/helpers/recoveryPhrase.ts | 16 + src/i18n/locales/en/translations.json | 14 +- src/i18n/locales/pt/translations.json | 14 +- src/navigators/ManageWalletsNavigator.tsx | 7 + src/services/auth/secretSources.ts | 23 ++ 16 files changed, 1082 insertions(+), 33 deletions(-) create mode 100644 __tests__/components/screens/ImportRecoveryPhraseScreen.test.tsx create mode 100644 src/components/screens/ImportRecoveryPhraseScreen.tsx diff --git a/__tests__/components/screens/AddAnotherWallet.test.tsx b/__tests__/components/screens/AddAnotherWallet.test.tsx index 2a8119912..8596be251 100644 --- a/__tests__/components/screens/AddAnotherWallet.test.tsx +++ b/__tests__/components/screens/AddAnotherWallet.test.tsx @@ -21,6 +21,10 @@ describe("AddAnotherWalletScreen", () => { it.each([ ["add-wallet-create", MANAGE_WALLETS_ROUTES.CREATE_WALLET_SCREEN], + [ + "add-wallet-import-phrase", + MANAGE_WALLETS_ROUTES.IMPORT_RECOVERY_PHRASE_SCREEN, + ], [ "add-wallet-import-secret-key", MANAGE_WALLETS_ROUTES.IMPORT_SECRET_KEY_SCREEN, @@ -37,7 +41,7 @@ describe("AddAnotherWalletScreen", () => { const { getByText, queryByText } = renderScreen(); expect(getByText("Add account")).toBeTruthy(); - expect(queryByText("Import Recovery Phrase")).toBeNull(); + expect(getByText("Import Recovery Phrase")).toBeTruthy(); expect(queryByText("Connect Hardware Wallet")).toBeNull(); }); }); diff --git a/__tests__/components/screens/ImportRecoveryPhraseScreen.test.tsx b/__tests__/components/screens/ImportRecoveryPhraseScreen.test.tsx new file mode 100644 index 000000000..e414e82eb --- /dev/null +++ b/__tests__/components/screens/ImportRecoveryPhraseScreen.test.tsx @@ -0,0 +1,122 @@ +import { act, fireEvent } from "@testing-library/react-native"; +import ImportRecoveryPhraseScreen from "components/screens/ImportRecoveryPhraseScreen"; +import { MANAGE_WALLETS_ROUTES } from "config/routes"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +const mockHoldPendingRecoveryPhrase = jest.fn(); +const mockClearPendingRecoveryPhrase = jest.fn(); +jest.mock("ducks/auth", () => ({ + // The providers read the store; the screen itself doesn't. + useAuthenticationStore: () => ({}), + holdPendingRecoveryPhrase: (phrase: string) => + mockHoldPendingRecoveryPhrase(phrase), + clearPendingRecoveryPhrase: (id?: string) => + mockClearPendingRecoveryPhrase(id), +})); + +const VALID_PHRASE = `${"abandon ".repeat(11)}about`; +const mockNavigate = jest.fn(); + +const renderScreen = () => + renderWithProviders( + jest.fn()), + } as never + } + route={{} as never} + />, + ); + +const typeAndSubmit = async ( + screen: ReturnType, + phrase: string, +) => { + fireEvent.changeText( + screen.getByTestId("import-recovery-phrase-input"), + phrase, + ); + fireEvent.press(screen.getByTestId("default-action-button")); + // The keyboard dismissal the screen waits on. + await act(async () => {}); +}; + +describe("ImportRecoveryPhraseScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockHoldPendingRecoveryPhrase.mockReturnValue("pending-phrase-7"); + }); + + it("asks for the phrase with Paste inside the field", () => { + const { getByText, getByPlaceholderText, getByTestId } = renderScreen(); + + expect(getByText("Import Recovery Phrase")).toBeTruthy(); + expect( + getByText("Add a wallet with its 12 or 24-word recovery phrase"), + ).toBeTruthy(); + expect(getByPlaceholderText("Enter your recovery phrase")).toBeTruthy(); + expect(getByTestId("clipboard-button")).toBeTruthy(); + }); + + it("holds the phrase and passes only the hold's id to the password step", async () => { + const screen = renderScreen(); + + await typeAndSubmit(screen, ` ${VALID_PHRASE.toUpperCase()} `); + + expect(mockHoldPendingRecoveryPhrase).toHaveBeenCalledWith(VALID_PHRASE); + expect(mockNavigate).toHaveBeenCalledWith( + MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN, + { kind: "importPhrase", pendingPhraseId: "pending-phrase-7" }, + ); + }); + + it("shows an error under the field for an invalid phrase and holds nothing", async () => { + const screen = renderScreen(); + + await typeAndSubmit(screen, "abandon ".repeat(12).trim()); + + expect( + screen.getByText( + "Invalid phrase - words must be in the correct order, lowercase, and separated by spaces", + ), + ).toBeTruthy(); + expect(mockHoldPendingRecoveryPhrase).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("clears the error once the phrase is edited", async () => { + const screen = renderScreen(); + await typeAndSubmit(screen, "abandon"); + + fireEvent.changeText( + screen.getByTestId("import-recovery-phrase-input"), + "abandon ability", + ); + + expect( + screen.queryByText( + "Invalid phrase - words must be in the correct order, lowercase, and separated by spaces", + ), + ).toBeNull(); + }); + + it("drops its hold when the screen goes away", async () => { + const screen = renderScreen(); + await typeAndSubmit(screen, VALID_PHRASE); + + screen.unmount(); + + expect(mockClearPendingRecoveryPhrase).toHaveBeenCalledWith( + "pending-phrase-7", + ); + }); + + it("drops nothing on leaving when no phrase was held", () => { + renderScreen().unmount(); + + expect(mockClearPendingRecoveryPhrase).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/screens/VerifyPasswordScreen.test.tsx b/__tests__/components/screens/VerifyPasswordScreen.test.tsx index 7274d7801..61bab120f 100644 --- a/__tests__/components/screens/VerifyPasswordScreen.test.tsx +++ b/__tests__/components/screens/VerifyPasswordScreen.test.tsx @@ -6,10 +6,12 @@ import React from "react"; const mockCreateAccount = jest.fn(); const mockAddRecoveryPhraseSource = jest.fn(); +const mockImportRecoveryPhrase = jest.fn(); jest.mock("ducks/auth", () => ({ useAuthenticationStore: () => ({ createAccount: mockCreateAccount, addRecoveryPhraseSource: mockAddRecoveryPhraseSource, + importRecoveryPhrase: mockImportRecoveryPhrase, isCreatingAccount: false, error: null, clearError: jest.fn(), @@ -23,11 +25,14 @@ jest.mock("components/templates/InputPasswordTemplate", () => { const { TouchableOpacity } = jest.requireActual("react-native"); return ({ handleContinue, + continueButtonText, }: { handleContinue: (password: string) => void; + continueButtonText: string; }) => ( handleContinue("hunter2")} /> ); @@ -48,6 +53,31 @@ describe("VerifyPasswordScreen", () => { jest.clearAllMocks(); mockCreateAccount.mockResolvedValue(undefined); mockAddRecoveryPhraseSource.mockResolvedValue(undefined); + mockImportRecoveryPhrase.mockResolvedValue(undefined); + }); + + it("imports the held phrase, under an Import button", async () => { + const { getByTestId } = renderScreen({ + kind: "importPhrase", + pendingPhraseId: "pending-phrase-3", + }); + + expect(getByTestId("continue").props.accessibilityLabel).toBe("Import"); + + fireEvent.press(getByTestId("continue")); + + expect(mockImportRecoveryPhrase).toHaveBeenCalledWith( + "hunter2", + "pending-phrase-3", + ); + expect(mockCreateAccount).not.toHaveBeenCalled(); + expect(mockAddRecoveryPhraseSource).not.toHaveBeenCalled(); + await waitFor(() => + expect(navigation.reset).toHaveBeenCalledWith({ + index: 0, + routes: [{ name: ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK }], + }), + ); }); it("adds a named account under the chosen phrase", async () => { diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 8c544bbbf..ef7b30b0b 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -27,6 +27,7 @@ import { generatePendingRecoveryPhrase, getPendingRecoveryPhrase, clearPendingRecoveryPhrase, + holdPendingRecoveryPhrase, } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useCollectiblesStore } from "ducks/collectibles"; @@ -145,6 +146,7 @@ jest.mock("stellar-hd-wallet", () => ({ default: { fromMnemonic: jest.fn(), generateMnemonic: jest.fn(), + validateMnemonic: jest.fn(() => true), }, })); @@ -354,6 +356,8 @@ describe("auth duck", () => { createAccount: useAuthenticationStore.getState().createAccount, addRecoveryPhraseSource: useAuthenticationStore.getState().addRecoveryPhraseSource, + importRecoveryPhrase: + useAuthenticationStore.getState().importRecoveryPhrase, initializeNetwork: useAuthenticationStore.getState().initializeNetwork, softLock: useAuthenticationStore.getState().softLock, navigateToLockScreen: @@ -5406,6 +5410,317 @@ describe("auth duck", () => { expect(getPendingRecoveryPhrase(id)).toBe(newPhrase); }); }); + + describe("importRecoveryPhrase", () => { + const importedPhrase = + "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima"; + const importedSourceId = `phrase:${importedPhrase}`; + + // Index 3 of the imported phrase is funded on mainnet; the rest are + // not. + const mockFunded = (funded: number[]) => { + (getAccount as jest.Mock).mockImplementation((publicKey: string) => + Promise.resolve( + funded.some( + (index) => publicKey === publicKeyAt(importedPhrase, index), + ) + ? { accountId: () => publicKey } + : null, + ), + ); + }; + + const mockImportSession = ( + accounts: Account[] = [phraseAccount(0)], + ) => { + const hook = mockAddAccountSession({ + accounts, + indices: { [mockPhraseSourceId]: [0] }, + }); + dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES] = JSON.stringify({ + [mockPhraseSourceId]: { + id: mockPhraseSourceId, + type: SecretSourceType.PHRASE, + label: "Seed Phrase 1", + createdAt: 1, + }, + }); + dataStorageMemory[STORAGE_KEYS.PINNED_SOURCE_ID] = mockPhraseSourceId; + let stored = 0; + sharedKeyManager.storeKey.mockImplementation(() => { + stored += 1; + return Promise.resolve({ id: `imported-${stored}` }); + }); + mockFunded([3]); + act(() => { + useAuthenticationStore.setState({ + importRecoveryPhrase: originalStoreMethods.importRecoveryPhrase, + }); + }); + return hook; + }; + + afterEach(() => { + clearPendingRecoveryPhrase(); + }); + + it("adds the phrase as a new source with index 0 and its funded indices, landing on index 0", async () => { + const { result } = mockImportSession(); + const id = holdPendingRecoveryPhrase(importedPhrase); + + await act(async () => { + await result.current.importRecoveryPhrase(mockPassword, id); + }); + + expect(result.current.error).toBeNull(); + // Indices 1 to 49 are looked up on mainnet. + expect(getAccount).toHaveBeenCalledTimes(49); + expect(getAccount).toHaveBeenCalledWith( + publicKeyAt(importedPhrase, 49), + NETWORKS.PUBLIC, + ); + expect(storedAccounts()).toEqual([ + phraseAccount(0), + expect.objectContaining({ + id: "imported-1", + sourceId: importedSourceId, + publicKey: publicKeyAt(importedPhrase, 0), + }), + expect.objectContaining({ + id: "imported-2", + sourceId: importedSourceId, + publicKey: publicKeyAt(importedPhrase, 3), + }), + ]); + expect(storedIndices()).toEqual({ + [mockPhraseSourceId]: [0], + [importedSourceId]: [0, 3], + }); + expect(storedSources()).toEqual([ + expect.objectContaining({ id: mockPhraseSourceId }), + expect.objectContaining({ + id: importedSourceId, + type: SecretSourceType.PHRASE, + label: "Seed Phrase 2", + }), + ]); + // The wallet's pin and phrase stay; the imported one joins them. + expect(dataStorageMemory[STORAGE_KEYS.PINNED_SOURCE_ID]).toBe( + mockPhraseSourceId, + ); + const written = writtenTemporaryStores(); + expect(written[written.length - 1].mnemonics).toEqual({ + [mockPhraseSourceId]: mockMnemonicPhrase, + [importedSourceId]: importedPhrase, + }); + expect(dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID]).toBe( + "imported-1", + ); + expect(getPendingRecoveryPhrase(id)).toBeNull(); + expect(linkWalletSources).toHaveBeenCalledTimes(1); + }); + + it("refuses a phrase the wallet already holds before looking anything up", async () => { + const { result } = mockImportSession(); + dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES] = JSON.stringify({ + [importedSourceId]: { + id: importedSourceId, + type: SecretSourceType.PHRASE, + label: "Seed Phrase 1", + createdAt: 1, + }, + }); + const id = holdPendingRecoveryPhrase(importedPhrase); + + await act(async () => { + await expect( + result.current.importRecoveryPhrase(mockPassword, id), + ).rejects.toThrow("authStore.error.recoveryPhraseAlreadyHeld"); + }); + + expect(result.current.error).toBe( + "authStore.error.recoveryPhraseAlreadyHeld", + ); + expect(getAccount).not.toHaveBeenCalled(); + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + }); + + it("refuses, and adds nothing, when one of the phrase's addresses is already in the wallet", async () => { + const { result } = mockImportSession([ + phraseAccount(0), + { + id: "imported-key", + sourceId: "key:imported", + name: "Account 2", + publicKey: publicKeyAt(importedPhrase, 3), + importedFromSecretKey: true, + }, + ]); + const id = holdPendingRecoveryPhrase(importedPhrase); + + await act(async () => { + await expect( + result.current.importRecoveryPhrase(mockPassword, id), + ).rejects.toThrow("authStore.error.accountAlreadyExists"); + }); + + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + expect(storedSources()).toHaveLength(1); + expect(storedAccounts()).toHaveLength(2); + }); + + it("stores nothing and keeps the phrase on a wrong password", async () => { + const { result } = mockImportSession(); + const id = holdPendingRecoveryPhrase(importedPhrase); + sharedKeyManager.loadKey.mockRejectedValue(new Error("bad password")); + + await act(async () => { + await expect( + result.current.importRecoveryPhrase("wrong", id), + ).rejects.toThrow(); + }); + + expect(getAccount).not.toHaveBeenCalled(); + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + expect(getPendingRecoveryPhrase(id)).toBe(importedPhrase); + }); + + it("refuses a phrase that fails validation", async () => { + const { result } = mockImportSession(); + (StellarHDWallet.validateMnemonic as jest.Mock).mockReturnValueOnce( + false, + ); + const id = holdPendingRecoveryPhrase(importedPhrase); + + await act(async () => { + await expect( + result.current.importRecoveryPhrase(mockPassword, id), + ).rejects.toThrow("authStore.error.invalidMnemonicPhrase"); + }); + + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + }); + + it("removes the source and its indices again when no account was stored", async () => { + const { result } = mockImportSession(); + const id = holdPendingRecoveryPhrase(importedPhrase); + sharedKeyManager.storeKey.mockRejectedValue(new Error("disk full")); + + await act(async () => { + await expect( + result.current.importRecoveryPhrase(mockPassword, id), + ).rejects.toThrow(); + }); + + expect(result.current.error).toBe( + "authStore.error.failedToImportRecoveryPhrase", + ); + expect(storedSources()).toEqual([ + expect.objectContaining({ id: mockPhraseSourceId }), + ]); + expect(storedIndices()?.[importedSourceId] ?? []).toEqual([]); + expect(storedAccounts()).toEqual([phraseAccount(0)]); + expect(dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID]).toBe( + "account-0", + ); + // The user can retry without typing the phrase again. + expect(getPendingRecoveryPhrase(id)).toBe(importedPhrase); + }); + + it("keeps the source, and puts the previous account back, when a later account fails to store", async () => { + const { result } = mockImportSession(); + const id = holdPendingRecoveryPhrase(importedPhrase); + sharedKeyManager.storeKey + .mockResolvedValueOnce({ id: "imported-1" }) + .mockRejectedValueOnce(new Error("disk full")); + + await act(async () => { + await expect( + result.current.importRecoveryPhrase(mockPassword, id), + ).rejects.toThrow(); + }); + + // A retry is refused as held rather than storing index 0 twice. + expect(storedSources()).toEqual([ + expect.objectContaining({ id: mockPhraseSourceId }), + expect.objectContaining({ id: importedSourceId }), + ]); + expect( + storedAccounts().map(({ id: accountId }) => accountId), + ).toEqual(["account-0", "imported-1"]); + expect(dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID]).toBe( + "account-0", + ); + }); + + it("stores one set of accounts when the same import runs twice at once", async () => { + const { result } = mockImportSession(); + const id = holdPendingRecoveryPhrase(importedPhrase); + + let outcomes: PromiseSettledResult[] = []; + await act(async () => { + outcomes = await Promise.allSettled([ + result.current.importRecoveryPhrase(mockPassword, id), + result.current.importRecoveryPhrase(mockPassword, id), + ]); + }); + + expect(outcomes.map(({ status }) => status).sort()).toEqual([ + "fulfilled", + "rejected", + ]); + expect(storedPublicKeys()).toEqual([ + publicKeyAt(importedPhrase, 0), + publicKeyAt(importedPhrase, 3), + ]); + expect(storedSources()).toHaveLength(2); + }); + + it("lets only the latest of two overlapping holds store the phrase", async () => { + const { result } = mockImportSession(); + + let outcomes: PromiseSettledResult[] = []; + await act(async () => { + const first = result.current.importRecoveryPhrase( + mockPassword, + holdPendingRecoveryPhrase(importedPhrase), + ); + // A second surface, or a retry, holds the same phrase again + // while the first import's lookups run. + const second = result.current.importRecoveryPhrase( + mockPassword, + holdPendingRecoveryPhrase(importedPhrase), + ); + outcomes = await Promise.allSettled([first, second]); + }); + + expect( + outcomes.filter(({ status }) => status === "fulfilled"), + ).toHaveLength(1); + expect(storedPublicKeys()).toEqual([ + publicKeyAt(importedPhrase, 0), + publicKeyAt(importedPhrase, 3), + ]); + }); + + it("stores nothing when a lock drops the phrase while the lookups run", async () => { + const { result } = mockImportSession(); + const id = holdPendingRecoveryPhrase(importedPhrase); + (getAccount as jest.Mock).mockImplementation(() => { + clearPendingRecoveryPhrase(); + return Promise.resolve(null); + }); + + await act(async () => { + await expect( + result.current.importRecoveryPhrase(mockPassword, id), + ).rejects.toThrow("authStore.error.pendingRecoveryPhraseMissing"); + }); + + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + expect(storedSources()).toHaveLength(1); + }); + }); }); // Port of the extension's account provenance (#75): type and source live diff --git a/__tests__/helpers/recoveryPhrase.test.ts b/__tests__/helpers/recoveryPhrase.test.ts index cea2a9fbb..6a9eddf63 100644 --- a/__tests__/helpers/recoveryPhrase.test.ts +++ b/__tests__/helpers/recoveryPhrase.test.ts @@ -1,4 +1,5 @@ import { + isValidImportMnemonic, normalizeRecoveryPhrase, normalizeAndTrimRecoveryPhrase, } from "helpers/recoveryPhrase"; @@ -97,4 +98,37 @@ describe("recoveryPhrase helpers", () => { expect(normalizeAndTrimRecoveryPhrase("\n \t\n \n")).toBe(""); }); }); + + describe("isValidImportMnemonic", () => { + const twelveWords = `${"abandon ".repeat(11)}about`; + const twentyFourWords = `${"abandon ".repeat(23)}art`; + + it("accepts a valid 12-word phrase", () => { + expect(isValidImportMnemonic(twelveWords)).toBe(true); + }); + + it("accepts a valid 24-word phrase", () => { + expect(isValidImportMnemonic(twentyFourWords)).toBe(true); + }); + + it("accepts a valid phrase in capitals with stray whitespace", () => { + expect(isValidImportMnemonic(` ${twelveWords.toUpperCase()}\n`)).toBe( + true, + ); + }); + + it("refuses a phrase whose checksum fails", () => { + expect(isValidImportMnemonic("abandon ".repeat(12).trim())).toBe(false); + }); + + it("refuses a valid BIP-39 length other than 12 or 24", () => { + expect(isValidImportMnemonic(`${"abandon ".repeat(14)}address`)).toBe( + false, + ); + }); + + it("refuses an empty phrase", () => { + expect(isValidImportMnemonic(" ")).toBe(false); + }); + }); }); diff --git a/__tests__/services/auth/secretSources.test.ts b/__tests__/services/auth/secretSources.test.ts index 35a8f37f9..b791332db 100644 --- a/__tests__/services/auth/secretSources.test.ts +++ b/__tests__/services/auth/secretSources.test.ts @@ -15,6 +15,7 @@ import { getSecretKeySourceId, getSecretSources, pinFirstSource, + removeSecretSource, setPinnedSourceId, } from "services/auth/secretSources"; @@ -361,3 +362,46 @@ describe("getDefaultMnemonic", () => { expect(await getDefaultMnemonic({ mnemonics: {} })).toBeNull(); }); }); + +describe("removeSecretSource", () => { + const register = async (id: string) => + addSecretSource({ id, type: SecretSourceType.PHRASE }); + + it("removes the record and leaves the others", async () => { + await register("phrase:a"); + await register("phrase:b"); + + await removeSecretSource("phrase:b"); + + expect(Object.keys(storedSources())).toEqual(["phrase:a"]); + expect(await getPinnedSourceId()).toBe("phrase:a"); + }); + + it("moves the pin to the earliest remaining source when the pinned one goes", async () => { + await register("phrase:a"); + await register("phrase:b"); + + await removeSecretSource("phrase:a"); + + expect(Object.keys(storedSources())).toEqual(["phrase:b"]); + expect(await getPinnedSourceId()).toBe("phrase:b"); + }); + + it("leaves no pin when the last source goes", async () => { + await register("phrase:a"); + + await removeSecretSource("phrase:a"); + + expect(storedSources()).toEqual({}); + expect(await getPinnedSourceId()).toBeNull(); + }); + + it("writes nothing for an unknown id", async () => { + await register("phrase:a"); + dataStorage.setItem.mockClear(); + + await removeSecretSource("phrase:missing"); + + expect(dataStorage.setItem).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/screens/AddAnotherWallet.tsx b/src/components/screens/AddAnotherWallet.tsx index 58519dee4..e8dfc7c97 100644 --- a/src/components/screens/AddAnotherWallet.tsx +++ b/src/components/screens/AddAnotherWallet.tsx @@ -20,10 +20,10 @@ type AddAnotherWalletScreenProps = NativeStackScreenProps< >; /** - * The ways to add a wallet, opened from the account switcher's Add wallet. - * Importing a further recovery phrase and connecting a hardware wallet are - * in the design but not offered yet: an import still replaces the wallet, - * and mobile has no hardware support. + * The ways to add a wallet, opened from the account switcher's Add wallet: + * create, import a further recovery phrase, or import a secret key. None of + * them replaces the wallet. Connecting a hardware wallet is in the design + * but not offered, since mobile has no hardware support. */ const AddAnotherWalletScreen: React.FC = ({ navigation, @@ -53,6 +53,24 @@ const AddAnotherWalletScreen: React.FC = ({ } testID="add-wallet-create" /> + + } + title={t("addAnotherWalletScreen.actions.importRecoveryPhrase")} + description={t( + "addAnotherWalletScreen.actions.importRecoveryPhraseDescription", + )} + onPress={() => + navigation.navigate( + MANAGE_WALLETS_ROUTES.IMPORT_RECOVERY_PHRASE_SCREEN, + ) + } + testID="add-wallet-import-phrase" + /> ; + +/** + * Import Recovery Phrase, from Add wallet: the phrase, then the password + * step. Unlike onboarding's Import Wallet, this adds the phrase to the + * wallet as a new source instead of replacing the wallet. + * + * Next checks the phrase and hands it to the auth store, which holds it for + * the password step; the route carries only the hold's id. The hold is + * dropped when this screen goes away. + */ +const ImportRecoveryPhraseScreen: React.FC = ({ + navigation, +}) => { + const { t } = useAppTranslation(); + const [phrase, setPhrase] = useState(""); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const heldPhraseIdRef = useRef(null); + // Set from Next until the screen has moved on, so a second tap meanwhile + // doesn't hold the phrase or navigate twice. + const isSubmittingRef = useRef(false); + + // Raises the keyboard once the screen has slid in: raising it while the + // screen still slides in glitches. Only on arrival. + useEffect(() => { + const unsubscribe = navigation.addListener("transitionEnd", (event) => { + if (event.data.closing) return; + + inputRef.current?.focus(); + unsubscribe(); + }); + + return unsubscribe; + }, [navigation]); + + // Leaving the flow drops the held phrase. Only this screen's hold, so a + // newer one is left alone. + useEffect( + () => () => { + if (heldPhraseIdRef.current) { + clearPendingRecoveryPhrase(heldPhraseIdRef.current); + } + }, + [], + ); + + const handleChangePhrase = useCallback((text: string) => { + setPhrase(text); + // The error was about the phrase as it was. + setError(null); + }, []); + + const handleNext = useCallback(() => { + if (!phrase || isSubmittingRef.current) return; + + // Typing word by word can leave a space after the last word, which the + // masked field hides and which would fail the check. + const normalizedPhrase = normalizeAndTrimRecoveryPhrase(phrase); + if (!isValidImportMnemonic(normalizedPhrase)) { + setError(t("authStore.error.invalidMnemonicPhrase")); + return; + } + + isSubmittingRef.current = true; + heldPhraseIdRef.current = holdPendingRecoveryPhrase(normalizedPhrase); + const pendingPhraseId = heldPhraseIdRef.current; + + // The keyboard goes first, so the password step comes in with it down. + waitForKeyboardDismiss().then(() => { + isSubmittingRef.current = false; + navigation.navigate(MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN, { + kind: "importPhrase", + pendingPhraseId, + }); + }); + }, [navigation, phrase, t]); + + return ( + + ); +}; + +export default ImportRecoveryPhraseScreen; diff --git a/src/components/screens/VerifyPasswordScreen.tsx b/src/components/screens/VerifyPasswordScreen.tsx index 68f0283b1..3ccc62ae7 100644 --- a/src/components/screens/VerifyPasswordScreen.tsx +++ b/src/components/screens/VerifyPasswordScreen.tsx @@ -15,9 +15,10 @@ type VerifyPasswordScreenProps = NativeStackScreenProps< >; /** - * The password step of Create new wallet. Confirms with the password, or - * Face ID when it's on, then creates what the route names: an account under - * an existing phrase, or the held new phrase and its first account. + * The password step of Create new wallet and Import Recovery Phrase. + * Confirms with the password, or Face ID when it's on, then creates what the + * route names: an account under an existing phrase, the held new phrase and + * its first account, or the held imported phrase and its accounts. */ const VerifyPasswordScreen: React.FC = ({ navigation, @@ -26,6 +27,7 @@ const VerifyPasswordScreen: React.FC = ({ const { createAccount, addRecoveryPhraseSource, + importRecoveryPhrase, isCreatingAccount, error, clearError, @@ -51,14 +53,20 @@ const VerifyPasswordScreen: React.FC = ({ (password: string) => { if (isCreatingAccount) return; - const create = - target.kind === "newPhrase" - ? addRecoveryPhraseSource( + const create = (() => { + switch (target.kind) { + case "newPhrase": + return addRecoveryPhraseSource( password, target.pendingPhraseId, target.accountName, - ) - : createAccount(password, target.sourceId, target.accountName); + ); + case "importPhrase": + return importRecoveryPhrase(password, target.pendingPhraseId); + default: + return createAccount(password, target.sourceId, target.accountName); + } + })(); create .then(() => { @@ -74,6 +82,7 @@ const VerifyPasswordScreen: React.FC = ({ [ addRecoveryPhraseSource, createAccount, + importRecoveryPhrase, isCreatingAccount, navigation, target, @@ -86,7 +95,11 @@ const VerifyPasswordScreen: React.FC = ({ error={error} isLoading={isCreatingAccount} handleContinue={handleUnlock} - continueButtonText={t("verifyPasswordScreen.actions.createNewWallet")} + continueButtonText={ + target.kind === "importPhrase" + ? t("verifyPasswordScreen.actions.importRecoveryPhrase") + : t("verifyPasswordScreen.actions.createNewWallet") + } title={t("verifyPasswordScreen.verifyPasswordTemplateTitle")} description={t("verifyPasswordScreen.verifyPasswordTemplateDescription")} showLogo={false} diff --git a/src/config/routes.ts b/src/config/routes.ts index f2457ea6e..7098ce9b3 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -106,6 +106,7 @@ export const MANAGE_WALLETS_ROUTES = { BACKUP_RECOVERY_PHRASE_SCREEN: "BackupRecoveryPhraseScreen", VERIFY_PASSWORD_SCREEN: "VerifyPasswordScreen", IMPORT_SECRET_KEY_SCREEN: "ImportSecretKeyScreen", + IMPORT_RECOVERY_PHRASE_SCREEN: "ImportRecoveryPhraseScreen", } as const; export const ADD_FUNDS_ROUTES = { @@ -234,12 +235,14 @@ export type SettingsStackParamList = { /** * What the password step creates once it's confirmed: an account under an - * existing phrase, or the first account of the phrase the backup step showed. - * The new phrase itself never travels in params; the auth store holds it. + * existing phrase, the first account of the phrase the backup step showed, + * or the accounts of a phrase the user typed. A phrase itself never travels + * in params; the auth store holds it. */ export type CreateWalletTarget = | { kind: "existingPhrase"; sourceId: string; accountName: string } - | { kind: "newPhrase"; pendingPhraseId: string; accountName: string }; + | { kind: "newPhrase"; pendingPhraseId: string; accountName: string } + | { kind: "importPhrase"; pendingPhraseId: string }; export type ManageWalletsStackParamList = { [MANAGE_WALLETS_ROUTES.ADD_ANOTHER_WALLET_SCREEN]: undefined; @@ -251,6 +254,7 @@ export type ManageWalletsStackParamList = { }; [MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN]: CreateWalletTarget; [MANAGE_WALLETS_ROUTES.IMPORT_SECRET_KEY_SCREEN]: undefined; + [MANAGE_WALLETS_ROUTES.IMPORT_RECOVERY_PHRASE_SCREEN]: undefined; }; export type AddFundsStackParamList = { diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 19971a77a..8071d041b 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -59,6 +59,10 @@ import { import { findAccountById, findAccountByPublicKey } from "helpers/findAccount"; import { createKeyManager } from "helpers/keyManager/keyManager"; import { markRebrandAnnouncementSeen } from "helpers/rebrandAnnouncement"; +import { + isValidImportMnemonic, + normalizeAndTrimRecoveryPhrase, +} from "helpers/recoveryPhrase"; import { clearScreenshotDek } from "helpers/screenshotCrypto"; import { scrubStrKeys } from "helpers/stellarStrKey"; import { clearWalletKitStorage } from "helpers/walletKitUtil"; @@ -71,6 +75,7 @@ import { getDerivationIndices, lowestUnusedIndex, markDerivationIndexUsed, + releaseDerivationIndex, } from "services/auth/derivationIndices"; import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; import { linkWalletSources } from "services/auth/linkSources"; @@ -83,6 +88,7 @@ import { getSecretKeySourceId, getSecretSources, pinFirstSource, + removeSecretSource, } from "services/auth/secretSources"; import { getWalletSources } from "services/auth/walletSources"; import { clearCachedWalletSources } from "services/auth/walletSourcesCache"; @@ -392,6 +398,15 @@ interface AuthActions { pendingPhraseId: string, accountName?: string, ) => Promise; + /** + * Stores the phrase `holdPendingRecoveryPhrase` is holding as a new phrase + * source, with its index 0 and every funded index up to 49. The first + * becomes active; nothing already in the wallet changes. + */ + importRecoveryPhrase: ( + password: string, + pendingPhraseId: string, + ) => Promise; selectAccount: (publicKey: string) => Promise; selectNetwork: (network: NETWORKS) => Promise; initializeNetwork: () => Promise; @@ -1303,11 +1318,12 @@ const deriveKeyPair = (params: DeriveKeypairParams) => { }; /* - A phrase generated for a new source in this wallet, held until the user has - seen it and confirmed with their password. Module state rather than store - state, so it never shows up in a store snapshot or a selector, and the - screens never pass it back: `addRecoveryPhraseSource` reads it from here. - Cleared once stored, when the flow is left, and on lock, log out or wipe. + A phrase for a new source in this wallet, generated or typed, held until + the user confirms with their password. Module state rather than store + state, so it never shows up in a store snapshot, a selector or navigation + params, and the screens never pass it back: `addRecoveryPhraseSource` and + `importRecoveryPhrase` read it from here. Cleared once stored, when the + flow is left, and on lock, log out or wipe. Each generation gets an id the screens carry instead of the phrase. The backup screen keeps the phrase it showed while the held one can be @@ -1318,17 +1334,16 @@ let pendingRecoveryPhrase: { mnemonicPhrase: string; id: string } | null = null; let pendingRecoveryPhraseCount = 0; /** - * Generates the phrase for a new source in an existing wallet and holds it - * until `addRecoveryPhraseSource` stores it. Nothing is persisted. A second - * call replaces the held phrase, so backing out and starting over never - * stores the abandoned one. + * Holds a phrase the user typed until `importRecoveryPhrase` stores it, in + * the same slot a generated phrase uses. A second call replaces it. * - * @returns The id of this generation, for the screens to pass along + * @param {string} mnemonicPhrase - The phrase, already normalized + * @returns The id of this hold, for the screens to pass along */ -export const generatePendingRecoveryPhrase = (): string => { +export const holdPendingRecoveryPhrase = (mnemonicPhrase: string): string => { pendingRecoveryPhraseCount += 1; pendingRecoveryPhrase = { - mnemonicPhrase: StellarHDWallet.generateMnemonic({ entropyBits: 128 }), + mnemonicPhrase, // Unique within the process, which is all it has to be: the phrase never // outlives it. id: `pending-phrase-${pendingRecoveryPhraseCount}`, @@ -1336,6 +1351,19 @@ export const generatePendingRecoveryPhrase = (): string => { return pendingRecoveryPhrase.id; }; +/** + * Generates the phrase for a new source in an existing wallet and holds it + * until `addRecoveryPhraseSource` stores it. Nothing is persisted. A second + * call replaces the held phrase, so backing out and starting over never + * stores the abandoned one. + * + * @returns The id of this generation, for the screens to pass along + */ +export const generatePendingRecoveryPhrase = (): string => + holdPendingRecoveryPhrase( + StellarHDWallet.generateMnemonic({ entropyBits: 128 }), + ); + /** * The held phrase, if it's still the one generation `pendingPhraseId` made. * @@ -1453,6 +1481,54 @@ const withFundedLookupTimeout = (lookup: Promise): Promise => { return Promise.race([lookup, timeout]).finally(() => clearTimeout(timer)); }; +/** How many indices a phrase import checks: 0, plus 1 to 49 if funded. */ +const PHRASE_IMPORT_DISCOVERY_DEPTH = 50; +const MAX_LOOKUPS_IN_FLIGHT = 10; + +/** + * The indices from `from` to `to` (inclusive) whose accounts are funded on + * mainnet, ascending. At most MAX_LOOKUPS_IN_FLIGHT lookups run at once. A + * lookup that fails or times out counts as unfunded. + * + * @param {StellarHDWallet} wallet - The phrase's wallet + * @param {number} from - The first index to check + * @param {number} to - The last index to check + * @returns {Promise} The funded indices + */ +const discoverFundedIndices = async ( + wallet: StellarHDWallet, + from: number, + to: number, +): Promise => { + const indices = Array.from({ length: to - from + 1 }, (_, i) => from + i); + const funded = new Set(); + let next = 0; + + const worker = async () => { + while (next < indices.length) { + const index = indices[next]; + next += 1; + // getAccount resolves to null on any failure, never rejects. + // eslint-disable-next-line no-await-in-loop + const account = await withFundedLookupTimeout( + getAccount(wallet.getPublicKey(index), NETWORKS.PUBLIC), + ); + if (account) { + funded.add(index); + } + } + }; + + await Promise.all( + Array.from( + { length: Math.min(MAX_LOOKUPS_IN_FLIGHT, indices.length) }, + worker, + ), + ); + + return indices.filter((index) => funded.has(index)); +}; + /** * Derive keypairs from the mnemonic phrase and verify if they exist on the network. * If they do, create accounts for them. @@ -2926,6 +3002,166 @@ const addRecoveryPhraseSource = async ( clearPendingRecoveryPhrase(); }; +/** What `prepareRecoveryPhraseImport` found, for `storeImportedRecoveryPhrase`. */ +interface RecoveryPhraseImportPlan { + mnemonicPhrase: string; + sourceId: string; + wallet: StellarHDWallet; + /** 0, then every funded index, ascending. */ + indices: number[]; +} + +/** + * The read-only half of a phrase import, run before the account-mutation + * lock: checks the held phrase and the password, refuses a phrase already + * held, and looks up which of the phrase's first 50 indices are funded. Up to + * 50 mainnet lookups at 5s each would hold the lock for as long as they take, + * so they run outside it, and `storeImportedRecoveryPhrase` checks again + * under it. + * + * @param {string} password - The user's password, verified against the active keystore + * @param {AuthStatus} authStatus - Current auth status, gating the session read + * @param {string} pendingPhraseId - The hold the import screen made + * @returns {Promise} The phrase, its source id and the indices to store + */ +const prepareRecoveryPhraseImport = async ( + password: string, + authStatus: AuthStatus, + pendingPhraseId: string, +): Promise => { + const heldPhrase = getPendingRecoveryPhrase(pendingPhraseId); + if (!heldPhrase) { + throw new Error(t("authStore.error.pendingRecoveryPhraseMissing")); + } + const mnemonicPhrase = normalizeAndTrimRecoveryPhrase(heldPhrase); + if (!isValidImportMnemonic(mnemonicPhrase)) { + throw new Error(t("authStore.error.invalidMnemonicPhrase")); + } + + // The new keystores are encrypted with the password, so a wrong one must + // fail here, not produce keystores the wallet can never open. + await getKeyFromKeyManager(password); + + const temporaryStore = await getTemporaryStore(authStatus); + if (!temporaryStore) { + throw new Error(t("authStore.error.temporaryStoreNotFound")); + } + + const sourceId = await getPhraseSourceId(mnemonicPhrase); + // Checked before discovery to skip the lookups for a held phrase. + if ((await getSecretSources())[sourceId]) { + throw new Error(t("authStore.error.recoveryPhraseAlreadyHeld")); + } + + const wallet = StellarHDWallet.fromMnemonic(mnemonicPhrase); + const fundedIndices = await discoverFundedIndices( + wallet, + 1, + PHRASE_IMPORT_DISCOVERY_DEPTH - 1, + ); + + return { mnemonicPhrase, sourceId, wallet, indices: [0, ...fundedIndices] }; +}; + +/** + * The write half of a phrase import, run under the account-mutation lock: + * stores the phrase as a new source with an account at each planned index, + * the first of which becomes active. Nothing already in the wallet changes. + * Every refusal comes before the first write. + * + * The source is registered first, so a failure after some accounts are + * stored leaves them under a labeled group and a retry is refused as held. + * If no account was listed, the source and its indices are removed again so + * a retry starts clean, and the previous account is made active again. + * + * @param {RecoveryPhraseImportPlan} plan - What `prepareRecoveryPhraseImport` found + * @param {string} password - The user's password, already verified + * @param {string} pendingPhraseId - The hold the import screen made + * @returns {Promise} + */ +const storeImportedRecoveryPhrase = async ( + { mnemonicPhrase, sourceId, wallet, indices }: RecoveryPhraseImportPlan, + password: string, + pendingPhraseId: string, +): Promise => { + // A lock or log out while the lookups ran drops the held phrase; so does + // starting over with another one. + if (getPendingRecoveryPhrase(pendingPhraseId) === null) { + throw new Error(t("authStore.error.pendingRecoveryPhraseMissing")); + } + + // Checked again: an import or add that finished while the lookups ran may + // have stored this phrase or one of its accounts since. + if ((await getSecretSources())[sourceId]) { + throw new Error(t("authStore.error.recoveryPhraseAlreadyHeld")); + } + + // 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 allAccounts = await getAllAccounts(); + const held = new Set(allAccounts.map(({ publicKey }) => publicKey)); + if (indices.some((index) => held.has(wallet.getPublicKey(index)))) { + throw new Error(t("authStore.error.accountAlreadyExists")); + } + + const previousActiveId = await dataStorage.getItem( + STORAGE_KEYS.ACTIVE_ACCOUNT_ID, + ); + + try { + await addSecretSource({ id: sourceId, type: SecretSourceType.PHRASE }); + + // Sequential: each one reads and appends the account list, and the key + // manager's id index is a read-modify-write too. + await indices.reduce(async (previous, index) => { + await previous; + await storeAccount({ + mnemonicPhrase, + phraseSourceId: sourceId, + sourceId, + derivationIndex: index, + password, + keyPair: { + publicKey: wallet.getPublicKey(index), + privateKey: wallet.getSecret(index), + }, + shouldRefreshHashKey: false, + // The phrase's first account is the one to land on. + shouldSetActiveAccount: index === 0, + }); + }, Promise.resolve()); + } catch (error) { + try { + const listedAny = (await getAllAccounts()).some( + (account) => account.sourceId === sourceId, + ); + if (!listedAny) { + await removeSecretSource(sourceId); + await indices.reduce(async (previous, index) => { + await previous; + await releaseDerivationIndex({ sourceId, index }); + }, Promise.resolve()); + } + if (previousActiveId) { + await dataStorage.setItem( + STORAGE_KEYS.ACTIVE_ACCOUNT_ID, + previousActiveId, + ); + } + } catch (rollbackError) { + logger.error( + "importRecoveryPhrase", + "Failed to roll back a failed phrase import", + rollbackError, + ); + } + throw error; + } + + clearPendingRecoveryPhrase(pendingPhraseId); +}; + /** * Imports a secret key and creates a new account * @@ -4188,6 +4424,52 @@ export const useAuthenticationStore = create()((set, get) => ({ } }, + importRecoveryPhrase: async (password: string, pendingPhraseId: string) => { + set({ isCreatingAccount: true, error: null }); + + try { + // The lookups run first, outside the lock; the checks that matter + // run again inside it, before anything is written. + const plan = await prepareRecoveryPhraseImport( + password, + get().authStatus, + pendingPhraseId, + ); + await withAccountMutationLock(() => + storeImportedRecoveryPhrase(plan, password, pendingPhraseId), + ); + + // The phrase's first account becomes active, as on createAccount. + clearAccountData({ keepAccountsFiatTotals: true }); + + await Promise.all([get().getAllAccounts(), get().fetchActiveAccount()]); + + analytics.track(AnalyticsEvent.ACCOUNT_SCREEN_ADD_ACCOUNT, { + number_of_accounts: get().allAccounts.length, + source: "imported_phrase", + }); + + // The imported phrase is a source of its own. + get().linkSources(); + + set({ isCreatingAccount: false, error: null }); + } catch (error) { + logger.error( + "useAuthenticationStore.importRecoveryPhrase", + "Failed to import recovery phrase", + error, + ); + set({ + error: getUserFacingError( + error, + "authStore.error.failedToImportRecoveryPhrase", + ), + isCreatingAccount: false, + }); + throw error; + } + }, + selectAccount: async (publicKey: string) => { set({ isSwitchingAccount: true, error: null }); try { diff --git a/src/helpers/recoveryPhrase.ts b/src/helpers/recoveryPhrase.ts index 8eae61f21..465b0cf2e 100644 --- a/src/helpers/recoveryPhrase.ts +++ b/src/helpers/recoveryPhrase.ts @@ -1,3 +1,5 @@ +import StellarHDWallet from "stellar-hd-wallet"; + export const normalizeRecoveryPhrase = (text: string): string => text .toLowerCase() @@ -7,3 +9,17 @@ export const normalizeRecoveryPhrase = (text: string): string => export const normalizeAndTrimRecoveryPhrase = (text: string): string => normalizeRecoveryPhrase(text).replace(/\n/g, " ").trim(); + +/** + * True when the phrase, once normalized, is 12 or 24 words and passes the + * BIP-39 English wordlist and checksum. Lighter than deriving the wallet, so + * a screen can check it on submit without blocking the JS thread. + */ +export const isValidImportMnemonic = (text: string): boolean => { + const normalized = normalizeAndTrimRecoveryPhrase(text); + const wordCount = normalized === "" ? 0 : normalized.split(" ").length; + if (wordCount !== 12 && wordCount !== 24) { + return false; + } + return StellarHDWallet.validateMnemonic(normalized); +}; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index c8bcac83d..fc222a8c6 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -268,6 +268,12 @@ "description": "Import your wallet with the 12 or 24-word recovery phrase", "textAreaPlaceholder": "Enter your recovery phrase" }, + "importRecoveryPhraseScreen": { + "title": "Import Recovery Phrase", + "description": "Add a wallet with its 12 or 24-word recovery phrase", + "textAreaPlaceholder": "Enter your recovery phrase", + "importButton": "Import" + }, "rebrandAnnouncementScreen": { "title": "Freighter is now Varial", "description": "Your Freighter wallet, now with a new name and look.", @@ -514,7 +520,8 @@ "disableBiometricsFailedMessage": "We couldn't verify your identity. Please try again.", "pendingRecoveryPhraseMissing": "The new recovery phrase is no longer available. Start again.", "recoveryPhraseAlreadyHeld": "This recovery phrase is already in your wallet", - "failedToAddRecoveryPhrase": "Failed to add the recovery phrase. Please try again." + "failedToAddRecoveryPhrase": "Failed to add the recovery phrase. Please try again.", + "failedToImportRecoveryPhrase": "Failed to import the recovery phrase. Please try again." }, "account": "Account {{number}}", "faceId": { @@ -908,6 +915,8 @@ "actions": { "createNewWallet": "Create new wallet", "createNewWalletDescription": "Using an existing or new recovery phrase", + "importRecoveryPhrase": "Import Recovery Phrase", + "importRecoveryPhraseDescription": "Import wallets using a recovery phrase.", "importSecretKey": "Import Secret Key", "importSecretKeyDescription": "Import a wallet using its secret key." } @@ -934,7 +943,8 @@ "verifyPasswordTemplateTitle": "Enter your password", "verifyPasswordTemplateDescription": "Enter your account password to verify your account.", "actions": { - "createNewWallet": "Create New Wallet" + "createNewWallet": "Create New Wallet", + "importRecoveryPhrase": "Import" } }, "logout": "Log out", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 83c3ad09f..21ba39569 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -234,6 +234,12 @@ "description": "Importe sua carteira com a frase de recuperação de 12 ou 24 palavras", "textAreaPlaceholder": "Digite sua frase de recuperação" }, + "importRecoveryPhraseScreen": { + "title": "Importar frase de recuperação", + "description": "Adicione uma carteira com a frase de recuperação de 12 ou 24 palavras", + "textAreaPlaceholder": "Digite sua frase de recuperação", + "importButton": "Importar" + }, "rebrandAnnouncementScreen": { "title": "O Freighter agora é Varial", "description": "Sua carteira Freighter, agora com um novo nome e visual.", @@ -480,7 +486,8 @@ "disableBiometricsFailedMessage": "Não conseguimos verificar sua identidade. Por favor, tente novamente.", "pendingRecoveryPhraseMissing": "A nova frase de recuperação não está mais disponível. Comece novamente.", "recoveryPhraseAlreadyHeld": "Esta frase de recuperação já está na sua carteira", - "failedToAddRecoveryPhrase": "Falha ao adicionar a frase de recuperação. Por favor, tente novamente." + "failedToAddRecoveryPhrase": "Falha ao adicionar a frase de recuperação. Por favor, tente novamente.", + "failedToImportRecoveryPhrase": "Falha ao importar a frase de recuperação. Por favor, tente novamente." }, "account": "Conta {{number}}", "faceId": { @@ -874,6 +881,8 @@ "actions": { "createNewWallet": "Criar nova carteira", "createNewWalletDescription": "Usando uma frase de recuperação existente ou nova", + "importRecoveryPhrase": "Importar frase de recuperação", + "importRecoveryPhraseDescription": "Importe carteiras usando uma frase de recuperação.", "importSecretKey": "Importar chave secreta", "importSecretKeyDescription": "Importe uma carteira usando a chave secreta dela." } @@ -900,7 +909,8 @@ "verifyPasswordTemplateTitle": "Digite sua senha", "verifyPasswordTemplateDescription": "Digite sua senha para verificar sua conta.", "actions": { - "createNewWallet": "Criar nova carteira" + "createNewWallet": "Criar nova carteira", + "importRecoveryPhrase": "Importar" } }, "logout": "Sair", diff --git a/src/navigators/ManageWalletsNavigator.tsx b/src/navigators/ManageWalletsNavigator.tsx index fc2b6f17e..a0ddd4631 100644 --- a/src/navigators/ManageWalletsNavigator.tsx +++ b/src/navigators/ManageWalletsNavigator.tsx @@ -5,6 +5,7 @@ import CustomNavigationHeader from "components/layout/CustomNavigationHeader"; import AddAnotherWalletScreen from "components/screens/AddAnotherWallet"; import BackupRecoveryPhraseScreen from "components/screens/BackupRecoveryPhraseScreen"; import CreateWalletScreen from "components/screens/CreateWalletScreen"; +import ImportRecoveryPhraseScreen from "components/screens/ImportRecoveryPhraseScreen"; import ImportSecretKeyScreen from "components/screens/ImportSecretKeyScreen"; import VerifyPasswordScreen from "components/screens/VerifyPasswordScreen"; import { @@ -71,6 +72,12 @@ export const ManageWalletsStackNavigator = () => { component={ImportSecretKeyScreen} options={resetNestedNavigationOptions(t("importSecretKeyScreen.title"))} /> + ); }; diff --git a/src/services/auth/secretSources.ts b/src/services/auth/secretSources.ts index 8b718a9b0..3016fa52d 100644 --- a/src/services/auth/secretSources.ts +++ b/src/services/auth/secretSources.ts @@ -159,6 +159,29 @@ export const addSecretSource = async ({ return source; }; +/** + * Unregisters one source. Only for undoing a registration that never got an + * account (a phrase import whose first account failed to store); nothing + * else removes sources. If the source was pinned, the pin moves to the + * earliest remaining source. + */ +export const removeSecretSource = async (id: string): Promise => { + const sources = await getSecretSources(); + if (!sources[id]) { + return; + } + + const rest = Object.fromEntries( + Object.entries(sources).filter(([sourceId]) => sourceId !== id), + ); + await dataStorage.setItem(STORAGE_KEYS.SECRET_SOURCES, JSON.stringify(rest)); + + if ((await getPinnedSourceId()) === id) { + await clearPinnedSourceId(); + await pinFirstSource(); + } +}; + export const clearSecretSources = async (): Promise => { await dataStorage.remove(STORAGE_KEYS.SECRET_SOURCES); }; From b70ac6273f971425373f7b308dcac7240f9be4e7 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 13 Nov 2026 10:01:31 -0700 Subject: [PATCH 07/14] feat(onboarding): import a wallet from a recovery phrase or secret key Port of stellar/freighter#3091. Import Wallet takes one input and works out whether it holds a 12 or 24-word recovery phrase or a Stellar secret key. The field keeps a secret key's case. A secret key goes through the new importWalletFromSecretKey store action, which stores one imported account with a secret-key source and no phrase. It checks the key before wiping anything and runs behind the account-mutation lock. If storing fails after the wipe, it wipes again so the user can retry. Set Password names the secret key in its acknowledgement, and both the Set Password and Biometrics steps can finish a secret-key import. Security hides "Show recovery phrase" for a wallet that holds only secret keys. The import is tracked as account_recovery.completed or account_recovery.failed with recovery_method "secret_key". --- .../BiometricsEnableScreen.test.tsx | 49 ++++- .../screens/ChoosePasswordScreen.test.tsx | 39 +++- .../screens/ImportWalletScreen.test.tsx | 140 ++++++------- .../SettingsScreen/SecurityScreen.test.tsx | 80 ++++++++ .../screens/ShowRecoveryPhraseScreen.test.tsx | 46 ++++- __tests__/ducks/auth.test.ts | 185 ++++++++++++++++++ __tests__/helpers/hasRecoveryPhrase.test.ts | 35 ++++ __tests__/helpers/parseImportSecret.test.ts | 67 +++++++ __tests__/helpers/recoveryPhrase.test.ts | 13 ++ __tests__/hooks/useFinishWalletSetup.test.tsx | 43 +++- src/components/RecoveryPhraseField.tsx | 16 +- src/components/RecoveryPhraseInput.tsx | 19 +- .../BiometricsEnableScreen.tsx | 64 +++--- .../screens/ChoosePasswordScreen.tsx | 22 ++- src/components/screens/ImportWalletScreen.tsx | 101 +++++----- .../SecurityScreen/SecurityScreen.tsx | 12 +- .../ShowRecoveryPhraseScreen.tsx | 29 ++- .../ImportRecoveryPhraseTemplate.tsx | 4 + src/ducks/auth.ts | 127 ++++++++++++ src/ducks/loginData.ts | 33 +++- src/helpers/hasRecoveryPhrase.ts | 11 ++ src/helpers/parseImportSecret.ts | 29 +++ src/helpers/recoveryPhrase.ts | 13 ++ src/hooks/useFinishWalletSetup.ts | 89 ++++++++- src/hooks/useHasRecoveryPhrase.ts | 39 ++++ src/i18n/locales/en/translations.json | 7 +- src/i18n/locales/pt/translations.json | 7 +- 27 files changed, 1125 insertions(+), 194 deletions(-) create mode 100644 __tests__/components/screens/SettingsScreen/SecurityScreen.test.tsx create mode 100644 __tests__/helpers/hasRecoveryPhrase.test.ts create mode 100644 __tests__/helpers/parseImportSecret.test.ts create mode 100644 src/helpers/hasRecoveryPhrase.ts create mode 100644 src/helpers/parseImportSecret.ts create mode 100644 src/hooks/useHasRecoveryPhrase.ts diff --git a/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx b/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx index ad1badab4..0900acc15 100644 --- a/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx +++ b/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx @@ -18,20 +18,27 @@ jest.mock("services/storage/storageFactory", () => ({ const mockSignUp = jest.fn(); const mockImportWallet = jest.fn(); +const mockImportWalletFromSecretKey = jest.fn(); const mockEnableBiometrics = jest.fn(); jest.mock("ducks/auth", () => ({ useAuthenticationStore: () => ({ isLoading: false, signUp: mockSignUp, importWallet: mockImportWallet, + importWalletFromSecretKey: mockImportWalletFromSecretKey, enableBiometrics: mockEnableBiometrics, }), })); const mockClearLoginData = jest.fn(); +const SECRET_KEY = "SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ"; +let mockLoginSecret: { + mnemonicPhrase: string | null; + secretKey: string | null; +} = { mnemonicPhrase: "abandon ability able", secretKey: null }; jest.mock("ducks/loginData", () => ({ useLoginDataStore: () => ({ - mnemonicPhrase: "abandon ability able", + ...mockLoginSecret, password: "password1", clearLoginData: mockClearLoginData, }), @@ -84,6 +91,10 @@ describe("BiometricsOnboardingScreen", () => { jest.clearAllMocks(); mockEnableBiometrics.mockReset(); mockBiometryType = BIOMETRY_TYPE.FACE_ID; + mockLoginSecret = { + mnemonicPhrase: "abandon ability able", + secretKey: null, + }; useWalletSetupOverlayStore.setState( useWalletSetupOverlayStore.getInitialState(), true, @@ -171,6 +182,24 @@ describe("BiometricsOnboardingScreen", () => { expect(mockSignUp).not.toHaveBeenCalled(); }); + it("imports from the secret key when Import Wallet kept one", async () => { + mockLoginSecret = { mnemonicPhrase: null, secretKey: SECRET_KEY }; + mockImportWalletFromSecretKey.mockResolvedValue(true); + const { getByText } = renderScreen(BiometricsSource.IMPORT_WALLET); + + fireEvent.press(getByText("Skip for now")); + + await waitFor(() => + expect(mockImportWalletFromSecretKey).toHaveBeenCalledWith({ + secretKey: SECRET_KEY, + password: "password1", + }), + ); + expect(mockImportWallet).not.toHaveBeenCalled(); + expect(mockSignUp).not.toHaveBeenCalled(); + await waitFor(() => expect(mockClearLoginData).toHaveBeenCalledTimes(1)); + }); + // Enable sets the wallet up with the password the biometric check hands // back. Only a setup that succeeds may turn biometrics on and drop the // phrase and password from memory. @@ -234,6 +263,24 @@ describe("BiometricsOnboardingScreen", () => { expect(mockClearLoginData).toHaveBeenCalledTimes(1); }); + it("imports the secret key with the password the check returns", async () => { + mockLoginSecret = { mnemonicPhrase: null, secretKey: SECRET_KEY }; + passBiometricCheck("biometric-password"); + mockImportWalletFromSecretKey.mockResolvedValue(true); + const { getByText } = renderScreen(BiometricsSource.IMPORT_WALLET); + + fireEvent.press(getByText("Enable Face ID")); + + await waitFor(() => + expect(mockSetIsBiometricsEnabled).toHaveBeenCalledWith(true), + ); + expect(mockImportWalletFromSecretKey).toHaveBeenCalledWith({ + secretKey: SECRET_KEY, + password: "biometric-password", + }); + expect(mockImportWallet).not.toHaveBeenCalled(); + }); + it("leaves biometrics off and keeps the phrase when setup fails", async () => { passBiometricCheck("biometric-password"); mockSignUp.mockResolvedValue(false); diff --git a/__tests__/components/screens/ChoosePasswordScreen.test.tsx b/__tests__/components/screens/ChoosePasswordScreen.test.tsx index 122d6abc1..3752c0eb3 100644 --- a/__tests__/components/screens/ChoosePasswordScreen.test.tsx +++ b/__tests__/components/screens/ChoosePasswordScreen.test.tsx @@ -94,7 +94,11 @@ const withKeyboardUp = () => { describe("ChoosePasswordScreen (Set Password)", () => { beforeEach(() => { jest.clearAllMocks(); - useLoginDataStore.setState({ password: null, mnemonicPhrase: null }); + useLoginDataStore.setState({ + password: null, + mnemonicPhrase: null, + secretKey: null, + }); }); afterEach(() => jest.restoreAllMocks()); @@ -343,6 +347,39 @@ describe("ChoosePasswordScreen (Set Password)", () => { expect(className).not.toContain("flex-1"); }); + it("names the phrase in the acknowledgement", () => { + const { getByText } = renderScreen(true); + + expect( + getByText( + "I understand imported recovery phrase can't be recovered and must be stored securely.", + ), + ).toBeTruthy(); + }); + + it("names the secret key in the acknowledgement and imports it", async () => { + useLoginDataStore.setState({ + mnemonicPhrase: null, + secretKey: "SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ", + }); + const screen = renderScreen(true); + + expect( + screen.getByText( + "I understand an imported secret key can't be recovered and must be stored securely.", + ), + ).toBeTruthy(); + + fillPasswords(screen); + fireEvent.press(screen.getByTestId("import-acknowledgement-checkbox")); + await pressContinue(screen); + + expect(mockGoBack).not.toHaveBeenCalled(); + expect(mockFinishWalletSetup).toHaveBeenCalledWith( + BiometricsSource.IMPORT_WALLET, + ); + }); + it("goes back to Import Wallet if the phrase is gone", async () => { useLoginDataStore.setState({ mnemonicPhrase: null }); const screen = renderScreen(true); diff --git a/__tests__/components/screens/ImportWalletScreen.test.tsx b/__tests__/components/screens/ImportWalletScreen.test.tsx index 6834a1448..321dcaf85 100644 --- a/__tests__/components/screens/ImportWalletScreen.test.tsx +++ b/__tests__/components/screens/ImportWalletScreen.test.tsx @@ -19,19 +19,10 @@ jest.mock("@react-navigation/native", () => { }; }); -const mockVerifyMnemonicPhrase = jest.fn(); -const mockClearError = jest.fn(); -let mockError: string | null = null; -jest.mock("ducks/auth", () => ({ - useAuthenticationStore: () => ({ - error: mockError, - clearError: mockClearError, - verifyMnemonicPhrase: mockVerifyMnemonicPhrase, - }), -})); - +// A valid 12-word phrase, and a secret key that isn't one. const PHRASE = - "abandon ability able about above absent absorb abstract absurd abuse access accident"; + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const SECRET_KEY = "SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ"; const mockNavigate = jest.fn(); const mockAddListener = jest.fn< () => void, @@ -56,11 +47,8 @@ const finishSlidingIn = () => { act(() => onTransitionEnd?.({ data: { closing: false } })); }; -/** - * Lets the check run once the keyboard is down: the button's spinner - * paints, then the phrase is checked and the screen moves on. - */ -const finishCheck = async () => { +/** Lets a pending keyboard dismissal and the move on settle. */ +const settle = async () => { await act(async () => {}); act(() => { jest.runAllTimers(); @@ -68,10 +56,10 @@ const finishCheck = async () => { await act(async () => {}); }; -/** Presses Next and lets the check run and the screen move on. */ +/** Presses Next and lets the screen move on. */ const pressNext = async ({ getByTestId }: ReturnType) => { fireEvent.press(getByTestId("default-action-button")); - await finishCheck(); + await settle(); }; /** @@ -96,8 +84,11 @@ describe("ImportWalletScreen", () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - mockError = null; - useLoginDataStore.setState({ password: null, mnemonicPhrase: null }); + useLoginDataStore.setState({ + password: null, + mnemonicPhrase: null, + secretKey: null, + }); }); afterEach(() => { @@ -105,23 +96,24 @@ describe("ImportWalletScreen", () => { jest.restoreAllMocks(); }); - it("asks for the phrase with one description and Paste inside the field", () => { + it("asks for a phrase or secret key with one description and Paste inside the field", () => { const { getByText, getByPlaceholderText, getByTestId, queryByText } = renderScreen(); expect(getByText("Import Wallet")).toBeTruthy(); expect( - getByText("Import your wallet with the 12 or 24-word recovery phrase"), + getByText("Import your wallet using a recovery phrase or secret key"), + ).toBeTruthy(); + expect( + getByPlaceholderText("Enter your recovery phrase or secret key"), ).toBeTruthy(); - expect(getByPlaceholderText("Enter your recovery phrase")).toBeTruthy(); expect(getByTestId("clipboard-button")).toBeTruthy(); expect(queryByText("Paste from clipboard")).toBeNull(); - expect(queryByText("Phrases are usually 12 or 24 words")).toBeNull(); }); // Raising the keyboard while the screen is still sliding in glitches, so // the field waits for the screen to settle. - it("focuses the phrase field once the screen has slid in", () => { + it("focuses the field once the screen has slid in", () => { const screen = renderScreen(); const input = screen.UNSAFE_getByType(TextInput).instance; const focus = input.focus as jest.Mock; @@ -147,31 +139,55 @@ describe("ImportWalletScreen", () => { ); }); - it("keeps Next disabled until a phrase is entered", async () => { + it("keeps Next disabled until something is entered", async () => { const screen = renderScreen(); await pressNext(screen); - expect(mockVerifyMnemonicPhrase).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); it("keeps a valid phrase in memory and asks for a password", async () => { - mockVerifyMnemonicPhrase.mockReturnValue(true); const screen = renderScreen(); fireEvent.changeText(screen.getByTestId("import-wallet-input"), PHRASE); await pressNext(screen); - expect(mockVerifyMnemonicPhrase).toHaveBeenCalledWith(PHRASE); expect(useLoginDataStore.getState().mnemonicPhrase).toBe(PHRASE); + expect(useLoginDataStore.getState().secretKey).toBeNull(); + expect(mockNavigate).toHaveBeenCalledWith( + AUTH_STACK_ROUTES.CHOOSE_PASSWORD_SCREEN, + { isImporting: true }, + ); + }); + + it("keeps a valid secret key in memory, case intact, and asks for a password", async () => { + const screen = renderScreen(); + + fireEvent.changeText(screen.getByTestId("import-wallet-input"), SECRET_KEY); + await pressNext(screen); + + expect(useLoginDataStore.getState().secretKey).toBe(SECRET_KEY); + expect(useLoginDataStore.getState().mnemonicPhrase).toBeNull(); expect(mockNavigate).toHaveBeenCalledWith( AUTH_STACK_ROUTES.CHOOSE_PASSWORD_SCREEN, { isImporting: true }, ); }); + it("lowercases a phrase typed with capitals before keeping it", async () => { + const screen = renderScreen(); + + fireEvent.changeText( + screen.getByTestId("import-wallet-input"), + PHRASE.toUpperCase(), + ); + await pressNext(screen); + + expect(useLoginDataStore.getState().mnemonicPhrase).toBe(PHRASE); + }); + it("drops a space typed after the last word", async () => { - mockVerifyMnemonicPhrase.mockReturnValue(true); const screen = renderScreen(); const input = () => screen.getByTestId("import-wallet-input"); @@ -181,81 +197,70 @@ describe("ImportWalletScreen", () => { fireEvent.changeText(input(), `${input().props.value} `); await pressNext(screen); - expect(mockVerifyMnemonicPhrase).toHaveBeenCalledWith(PHRASE); expect(useLoginDataStore.getState().mnemonicPhrase).toBe(PHRASE); }); - it("stays put on an invalid phrase", async () => { - mockVerifyMnemonicPhrase.mockReturnValue(false); + it.each([ + ["a phrase with a bad checksum", PHRASE.replace(/about$/, "abandon")], + ["a secret key with a bad checksum", `${SECRET_KEY.slice(0, -1)}A`], + ["something else", "hello"], + ])("stays put with an error on %s", async (_label, text) => { const screen = renderScreen(); - fireEvent.changeText(screen.getByTestId("import-wallet-input"), PHRASE); + fireEvent.changeText(screen.getByTestId("import-wallet-input"), text); await pressNext(screen); expect(mockNavigate).not.toHaveBeenCalled(); expect(useLoginDataStore.getState().mnemonicPhrase).toBeNull(); + expect(useLoginDataStore.getState().secretKey).toBeNull(); + expect( + screen.getByText("Enter a valid recovery phrase or secret key"), + ).toBeTruthy(); }); - it("shows the store's error under the field", () => { - mockError = "Invalid recovery phrase"; - const { getByText } = renderScreen(); - - expect(getByText("Invalid recovery phrase")).toBeTruthy(); - }); - - it("clears the error as soon as the phrase changes", () => { - mockError = "Invalid recovery phrase"; + it("clears the error as soon as the text changes", async () => { const screen = renderScreen(); - // Coming into view clears it once; only the edit counts here. - mockClearError.mockClear(); + const input = () => screen.getByTestId("import-wallet-input"); + fireEvent.changeText(input(), "hello"); + await pressNext(screen); + const error = "Enter a valid recovery phrase or secret key"; + expect(screen.getByText(error)).toBeTruthy(); - fireEvent.changeText(screen.getByTestId("import-wallet-input"), PHRASE); + fireEvent.changeText(input(), "hello there"); - expect(mockClearError).toHaveBeenCalledTimes(1); + expect(screen.queryByText(error)).toBeNull(); }); it("starts over whenever it comes back into view", async () => { - mockVerifyMnemonicPhrase.mockReturnValue(true); const screen = renderScreen(); - fireEvent.changeText(screen.getByTestId("import-wallet-input"), PHRASE); + fireEvent.changeText(screen.getByTestId("import-wallet-input"), SECRET_KEY); await pressNext(screen); - expect(useLoginDataStore.getState().mnemonicPhrase).toBe(PHRASE); + expect(useLoginDataStore.getState().secretKey).toBe(SECRET_KEY); act(() => { mockRefocus(); }); + expect(useLoginDataStore.getState().secretKey).toBeNull(); expect(useLoginDataStore.getState().mnemonicPhrase).toBeNull(); expect(screen.getByTestId("clipboard-button")).toBeTruthy(); - expect(mockClearError).toHaveBeenCalled(); }); // Set Password comes in once the keyboard is down. Arriving with it still // up left the acknowledgement and the button under it. describe("leaving with the keyboard up", () => { - beforeEach(() => { - mockVerifyMnemonicPhrase.mockReturnValue(true); - }); - - // The keyboard goes as soon as Next is tapped, so the button has - // settled at the bottom before its spinner shows and the check runs. - it("closes the keyboard at once, then checks the phrase with the button at the bottom", async () => { + it("closes the keyboard, then asks for a password", async () => { const hideKeyboard = withKeyboardUp(); const dismiss = jest.spyOn(Keyboard, "dismiss"); const screen = renderScreen(); - const spinner = () => screen.queryByTestId("button-loading-indicator"); fireEvent.changeText(screen.getByTestId("import-wallet-input"), PHRASE); await pressNext(screen); expect(dismiss).toHaveBeenCalled(); - expect(spinner()).toBeNull(); - expect(mockVerifyMnemonicPhrase).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); await hideKeyboard(); - expect(spinner()).not.toBeNull(); - - await finishCheck(); - expect(mockVerifyMnemonicPhrase).toHaveBeenCalledWith(PHRASE); + await settle(); expect(mockNavigate).toHaveBeenCalledWith( AUTH_STACK_ROUTES.CHOOSE_PASSWORD_SCREEN, { isImporting: true }, @@ -270,9 +275,8 @@ describe("ImportWalletScreen", () => { await pressNext(screen); await pressNext(screen); await hideKeyboard(); - await finishCheck(); + await settle(); - expect(mockVerifyMnemonicPhrase).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledTimes(1); }); }); diff --git a/__tests__/components/screens/SettingsScreen/SecurityScreen.test.tsx b/__tests__/components/screens/SettingsScreen/SecurityScreen.test.tsx new file mode 100644 index 000000000..7b35bc4a7 --- /dev/null +++ b/__tests__/components/screens/SettingsScreen/SecurityScreen.test.tsx @@ -0,0 +1,80 @@ +import { act, fireEvent } from "@testing-library/react-native"; +import SecurityScreen from "components/screens/SettingsScreen/SecurityScreen/SecurityScreen"; +import { SETTINGS_ROUTES } from "config/routes"; +import { SecretSourceMap, SecretSourceType } from "config/types"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; +import { getSecretSources } from "services/auth/secretSources"; + +jest.mock("@react-navigation/native", () => { + const { useEffect } = jest.requireActual("react"); + return { + ...jest.requireActual("@react-navigation/native"), + useFocusEffect: (effect: () => void) => useEffect(effect, [effect]), + }; +}); + +jest.mock("hooks/useBiometrics", () => ({ + useBiometrics: () => ({ biometryType: null }), +})); + +jest.mock("services/auth/secretSources", () => ({ + getSecretSources: jest.fn(), +})); + +const sources = (...types: SecretSourceType[]): SecretSourceMap => + Object.fromEntries( + types.map((type, index) => [ + `source-${index}`, + { id: `source-${index}`, type, label: `Source ${index}`, createdAt: 1 }, + ]), + ); + +const mockNavigate = jest.fn(); +const renderScreen = async () => { + const screen = renderWithProviders( + , + ); + // Lets the sources read settle. + await act(async () => {}); + return screen; +}; + +describe("SecurityScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("offers Show recovery phrase for a wallet with a phrase", async () => { + (getSecretSources as jest.Mock).mockResolvedValue( + sources(SecretSourceType.PHRASE, SecretSourceType.SECRET_KEY), + ); + const { getByTestId } = await renderScreen(); + + fireEvent.press(getByTestId("show-recovery-phrase-button")); + + expect(mockNavigate).toHaveBeenCalledWith( + SETTINGS_ROUTES.SHOW_RECOVERY_PHRASE_SCREEN, + ); + }); + + it("hides Show recovery phrase for a wallet holding only secret keys", async () => { + (getSecretSources as jest.Mock).mockResolvedValue( + sources(SecretSourceType.SECRET_KEY), + ); + const { queryByTestId, getByTestId } = await renderScreen(); + + expect(queryByTestId("show-recovery-phrase-button")).toBeNull(); + expect(getByTestId("auto-lock-button")).toBeTruthy(); + }); + + it("keeps Show recovery phrase when the sources can't be read", async () => { + (getSecretSources as jest.Mock).mockRejectedValue(new Error("boom")); + const { getByTestId } = await renderScreen(); + + expect(getByTestId("show-recovery-phrase-button")).toBeTruthy(); + }); +}); diff --git a/__tests__/components/screens/ShowRecoveryPhraseScreen.test.tsx b/__tests__/components/screens/ShowRecoveryPhraseScreen.test.tsx index 11214ae54..72beef013 100644 --- a/__tests__/components/screens/ShowRecoveryPhraseScreen.test.tsx +++ b/__tests__/components/screens/ShowRecoveryPhraseScreen.test.tsx @@ -1,9 +1,15 @@ -import { fireEvent } from "@testing-library/react-native"; +import { act, fireEvent } from "@testing-library/react-native"; import { FOCUS_RING_WIDTH } from "components/FocusRing"; import ShowRecoveryPhraseScreen from "components/screens/SettingsScreen/SecurityScreen/ShowRecoveryPhraseScreen/ShowRecoveryPhraseScreen"; +import { SecretSourceType } from "config/types"; import { renderWithProviders } from "helpers/testUtils"; import React from "react"; import { StyleSheet } from "react-native"; +import { getSecretSources } from "services/auth/secretSources"; + +jest.mock("services/auth/secretSources", () => ({ + getSecretSources: jest.fn(() => Promise.resolve({})), +})); jest.mock("components/sds/BiometricToggleButton", () => ({ BiometricToggleButton: () => null, @@ -16,15 +22,20 @@ jest.mock("ducks/auth", () => ({ }), })); +const mockGoBack = jest.fn(); const renderScreen = () => renderWithProviders( , ); describe("ShowRecoveryPhraseScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it("asks for the password in the shared field, with its label and eye", () => { const { getByText, getByTestId, getByLabelText } = renderScreen(); @@ -49,4 +60,35 @@ describe("ShowRecoveryPhraseScreen", () => { StyleSheet.flatten(scroll.props.contentContainerStyle).paddingHorizontal, ).toBe(FOCUS_RING_WIDTH); }); + + // Security hides the way here for a wallet onboarded from a secret key. + it("goes back for a wallet holding only secret keys", async () => { + (getSecretSources as jest.Mock).mockResolvedValueOnce({ + key: { + id: "key", + type: SecretSourceType.SECRET_KEY, + label: "Secret Key 1", + createdAt: 1, + }, + }); + renderScreen(); + await act(async () => {}); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it("stays for a wallet with a phrase", async () => { + (getSecretSources as jest.Mock).mockResolvedValueOnce({ + phrase: { + id: "phrase", + type: SecretSourceType.PHRASE, + label: "Seed Phrase 1", + createdAt: 1, + }, + }); + renderScreen(); + await act(async () => {}); + + expect(mockGoBack).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index ef7b30b0b..71871e3bc 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -1,6 +1,7 @@ import { NavigationContainerRef } from "@react-navigation/native"; import { Keypair } from "@stellar/stellar-sdk"; import { act, renderHook } from "@testing-library/react-hooks"; +import { AnalyticsEvent } from "config/analyticsConfig"; import { AUTO_LOCK_TIMER, DEFAULT_AUTO_LOCK_TIMER, @@ -46,6 +47,7 @@ import { createKeyManager } from "helpers/keyManager/keyManager"; import { clearScreenshotDek } from "helpers/screenshotCrypto"; import { AppState } from "react-native"; import { getSupportedBiometryType, BIOMETRY_TYPE } from "react-native-keychain"; +import { analytics } from "services/analytics"; import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; import { linkWalletSources } from "services/auth/linkSources"; import { clearLinkStatus, getLinkConflicts } from "services/auth/linkStatus"; @@ -346,6 +348,8 @@ describe("auth duck", () => { logout: useAuthenticationStore.getState().logout, signUp: useAuthenticationStore.getState().signUp, importWallet: useAuthenticationStore.getState().importWallet, + importWalletFromSecretKey: + useAuthenticationStore.getState().importWalletFromSecretKey, fetchActiveAccount: useAuthenticationStore.getState().fetchActiveAccount, getAuthStatus: useAuthenticationStore.getState().getAuthStatus, selectAccount: useAuthenticationStore.getState().selectAccount, @@ -372,6 +376,7 @@ describe("auth duck", () => { useAuthenticationStore.getState().signUp = jest.fn(); useAuthenticationStore.getState().signIn = jest.fn(); useAuthenticationStore.getState().importWallet = jest.fn(); + useAuthenticationStore.getState().importWalletFromSecretKey = jest.fn(); useAuthenticationStore.getState().getAuthStatus = jest.fn(); useAuthenticationStore.getState().fetchActiveAccount = jest.fn(); useAuthenticationStore.getState().refreshActiveAccount = jest.fn(); @@ -2886,6 +2891,7 @@ describe("auth duck", () => { storeKey: jest.Mock; loadKey: jest.Mock; loadAllKeyIds: jest.Mock; + removeKey: jest.Mock; }; }>("helpers/keyManager/keyManager"); @@ -3171,6 +3177,185 @@ describe("auth duck", () => { ); }); + describe("importWalletFromSecretKey", () => { + const previousSources = () => { + dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId; + dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([ + { + id: mockAccountId, + name: mockAccountName, + publicKey: mockPublicKey, + }, + ]); + dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES] = JSON.stringify({ + [mockPhraseSourceId]: { + id: mockPhraseSourceId, + type: SecretSourceType.PHRASE, + label: "Seed Phrase 1", + createdAt: 1, + }, + }); + dataStorageMemory[STORAGE_KEYS.PINNED_SOURCE_ID] = mockPhraseSourceId; + }; + + const runImport = async (secretKey = importedSecretKey) => { + const { result } = renderHook(() => useAuthenticationStore()); + act(() => { + useAuthenticationStore.setState({ + importWalletFromSecretKey: + originalStoreMethods.importWalletFromSecretKey, + }); + }); + let succeeded = false; + await act(async () => { + succeeded = await result.current.importWalletFromSecretKey({ + secretKey, + password: mockPassword, + }); + }); + return { result, succeeded }; + }; + + beforeEach(() => { + wipeNonSensitiveData(); + sharedKeyManager.storeKey.mockResolvedValue({ + id: "imported-account-id", + }); + }); + + it("replaces the wallet with one imported account and no phrase", async () => { + previousSources(); + + const { result, succeeded } = await runImport(); + + expect(succeeded).toBe(true); + expect(result.current.authStatus).toBe(AUTH_STATUS.AUTHENTICATED); + expect(result.current.error).toBeNull(); + expect(sharedKeyManager.removeKey).toHaveBeenCalledWith(mockAccountId); + expect(sharedKeyManager.storeKey).toHaveBeenCalledWith( + expect.objectContaining({ + key: expect.objectContaining({ + publicKey: importedPublicKey, + privateKey: importedSecretKey, + extra: { imported: true }, + }), + }), + ); + expect( + JSON.parse(dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST]), + ).toEqual([ + expect.objectContaining({ + id: "imported-account-id", + sourceId: `key:${importedSecretKey}`, + publicKey: importedPublicKey, + importedFromSecretKey: true, + }), + ]); + expect(dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID]).toBe( + "imported-account-id", + ); + expect(storedSources()).toEqual([ + expect.objectContaining({ + id: `key:${importedSecretKey}`, + type: SecretSourceType.SECRET_KEY, + label: "Secret Key 1", + }), + ]); + expect(dataStorageMemory[STORAGE_KEYS.PINNED_SOURCE_ID]).toBe( + `key:${importedSecretKey}`, + ); + const [written] = writtenTemporaryStores(); + expect(written.mnemonics).toEqual({}); + expect(written.privateKeys["imported-account-id"]).toBe( + importedSecretKey, + ); + expect(linkWalletSources).toHaveBeenCalledTimes(1); + expect(analytics.track).toHaveBeenCalledWith( + AnalyticsEvent.RECOVER_ACCOUNT_SUCCESS, + { recovery_method: "secret_key" }, + ); + }); + + it("leaves the wallet untouched when the key is invalid", async () => { + previousSources(); + const before = { ...dataStorageMemory }; + + const { result, succeeded } = await runImport( + `${importedSecretKey.slice(0, -1)}A`, + ); + + expect(succeeded).toBe(false); + expect(result.current.error).toBe("authStore.error.invalidSecretKey"); + expect(clearNonSensitiveData).not.toHaveBeenCalled(); + expect(sharedKeyManager.removeKey).not.toHaveBeenCalled(); + expect(sharedKeyManager.storeKey).not.toHaveBeenCalled(); + expect(dataStorageMemory).toEqual(before); + expect(analytics.track).toHaveBeenCalledWith( + AnalyticsEvent.RECOVER_ACCOUNT_FAIL, + { recovery_method: "secret_key", reason_code: "invalid_secret_key" }, + ); + }); + + it.each([ + [ + "the key store", + () => sharedKeyManager.storeKey.mockRejectedValue(new Error("boom")), + ], + [ + "the source registration", + () => + (dataStorage.setItem as jest.Mock).mockImplementation( + (key: STORAGE_KEYS, value: string) => { + if (key === STORAGE_KEYS.SECRET_SOURCES) { + return Promise.reject(new Error("write failed")); + } + dataStorageMemory[key] = value; + return Promise.resolve(); + }, + ), + ], + ])( + "leaves a fresh install when %s fails after the wipe", + async (_label, fail) => { + previousSources(); + fail(); + + const { result, succeeded } = await runImport(); + + expect(succeeded).toBe(false); + expect(result.current.error).toBe( + "authStore.error.failedToImportWallet", + ); + // Wiped before storing, and again to drop the partial writes. + expect(clearNonSensitiveData).toHaveBeenCalledTimes(2); + expect(dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST]).toBeUndefined(); + expect( + dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES], + ).toBeUndefined(); + expect(analytics.track).toHaveBeenCalledWith( + AnalyticsEvent.RECOVER_ACCOUNT_FAIL, + expect.objectContaining({ recovery_method: "secret_key" }), + ); + expect(linkWalletSources).not.toHaveBeenCalled(); + }, + ); + + it("never logs or tracks the key", async () => { + sharedKeyManager.storeKey.mockRejectedValue( + new Error(`could not store ${importedSecretKey}`), + ); + + await runImport(); + + const calls = JSON.stringify([ + (logger.error as jest.Mock).mock.calls, + (logger.warn as jest.Mock).mock.calls, + (analytics.track as jest.Mock).mock.calls, + ]); + expect(calls).not.toContain(importedSecretKey); + }); + }); + describe("signIn", () => { beforeEach(() => { dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId; diff --git a/__tests__/helpers/hasRecoveryPhrase.test.ts b/__tests__/helpers/hasRecoveryPhrase.test.ts new file mode 100644 index 000000000..c03b25d79 --- /dev/null +++ b/__tests__/helpers/hasRecoveryPhrase.test.ts @@ -0,0 +1,35 @@ +import { SecretSource, SecretSourceType } from "config/types"; +import { hasRecoveryPhrase } from "helpers/hasRecoveryPhrase"; + +const source = (type: SecretSourceType, id: string): SecretSource => ({ + id, + type, + label: type === SecretSourceType.PHRASE ? "Seed Phrase 1" : "Secret Key 1", + createdAt: 1, +}); + +describe("hasRecoveryPhrase", () => { + it("is false for a wallet holding only secret keys", () => { + expect( + hasRecoveryPhrase([ + source(SecretSourceType.SECRET_KEY, "a"), + source(SecretSourceType.SECRET_KEY, "b"), + ]), + ).toBe(false); + }); + + it("is true once the wallet holds a phrase", () => { + expect( + hasRecoveryPhrase([ + source(SecretSourceType.SECRET_KEY, "a"), + source(SecretSourceType.PHRASE, "b"), + ]), + ).toBe(true); + }); + + // No sources yet (not loaded, or a wallet from before source records) + // must not hide a phrase the wallet may hold. + it("is true when there are no sources", () => { + expect(hasRecoveryPhrase([])).toBe(true); + }); +}); diff --git a/__tests__/helpers/parseImportSecret.test.ts b/__tests__/helpers/parseImportSecret.test.ts new file mode 100644 index 000000000..aa08d9b7c --- /dev/null +++ b/__tests__/helpers/parseImportSecret.test.ts @@ -0,0 +1,67 @@ +import { parseImportSecret } from "helpers/parseImportSecret"; + +const PHRASE_12 = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const PHRASE_24 = `${"abandon ".repeat(23)}art`; +const SECRET_KEY = "SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ"; + +describe("parseImportSecret", () => { + it("reads a secret key, trimmed, with its case intact", () => { + expect(parseImportSecret(` ${SECRET_KEY}\n`)).toEqual({ + type: "secret_key", + secretKey: SECRET_KEY, + }); + }); + + it("rejects a lowercased secret key", () => { + expect(parseImportSecret(SECRET_KEY.toLowerCase())).toEqual({ + type: "invalid", + }); + }); + + it("rejects a secret key with a bad checksum", () => { + expect(parseImportSecret(`${SECRET_KEY.slice(0, -1)}A`)).toEqual({ + type: "invalid", + }); + }); + + it("rejects a public key", () => { + expect( + parseImportSecret( + "GB3JDWCQJCWMJ3IILWIGDTQJJC5567PGVEVXSCVPEQOTDN64VJBDQBYX", + ), + ).toEqual({ type: "invalid" }); + }); + + it.each([ + ["12", PHRASE_12], + ["24", PHRASE_24], + ])("reads a %s-word phrase", (_count, phrase) => { + expect(parseImportSecret(phrase)).toEqual({ + type: "phrase", + mnemonicPhrase: phrase, + }); + }); + + it("normalizes a phrase's case and spacing", () => { + expect( + parseImportSecret(` ${PHRASE_12.toUpperCase().replace(/ /g, " ")}\n`), + ).toEqual({ type: "phrase", mnemonicPhrase: PHRASE_12 }); + }); + + it("rejects a phrase with a bad checksum", () => { + expect(parseImportSecret(PHRASE_12.replace(/about$/, "abandon"))).toEqual({ + type: "invalid", + }); + }); + + it("rejects a phrase of the wrong length", () => { + expect(parseImportSecret("abandon ".repeat(14).trim())).toEqual({ + type: "invalid", + }); + }); + + it.each(["", " ", "hello"])("rejects %p", (input) => { + expect(parseImportSecret(input)).toEqual({ type: "invalid" }); + }); +}); diff --git a/__tests__/helpers/recoveryPhrase.test.ts b/__tests__/helpers/recoveryPhrase.test.ts index 6a9eddf63..ddd9e27d3 100644 --- a/__tests__/helpers/recoveryPhrase.test.ts +++ b/__tests__/helpers/recoveryPhrase.test.ts @@ -1,5 +1,6 @@ import { isValidImportMnemonic, + normalizeImportSecretInput, normalizeRecoveryPhrase, normalizeAndTrimRecoveryPhrase, } from "helpers/recoveryPhrase"; @@ -131,4 +132,16 @@ describe("recoveryPhrase helpers", () => { expect(isValidImportMnemonic(" ")).toBe(false); }); }); + + describe("normalizeImportSecretInput", () => { + it("keeps case and accents, collapsing only spacing", () => { + expect(normalizeImportSecretInput("SAbc\n Déf ")).toBe("SAbc Déf "); + }); + + it("trims when asked", () => { + expect(normalizeImportSecretInput(" SABC \n", { trim: true })).toBe( + "SABC", + ); + }); + }); }); diff --git a/__tests__/hooks/useFinishWalletSetup.test.tsx b/__tests__/hooks/useFinishWalletSetup.test.tsx index 03717ad70..76f770bc0 100644 --- a/__tests__/hooks/useFinishWalletSetup.test.tsx +++ b/__tests__/hooks/useFinishWalletSetup.test.tsx @@ -15,11 +15,13 @@ jest.mock("@react-navigation/native", () => ({ const mockSignUp = jest.fn(); const mockImportWallet = jest.fn(); +const mockImportWalletFromSecretKey = jest.fn(); const mockStoreBiometricPassword = jest.fn(); jest.mock("ducks/auth", () => ({ useAuthenticationStore: () => ({ signUp: mockSignUp, importWallet: mockImportWallet, + importWalletFromSecretKey: mockImportWalletFromSecretKey, storeBiometricPassword: mockStoreBiometricPassword, }), })); @@ -37,6 +39,7 @@ jest.mock("hooks/useSetupFailedToast", () => ({ const PHRASE = "abandon ability able about above absent absorb abstract absurd abuse access accident"; const PASSWORD = "password1"; +const SECRET_KEY = "SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ"; const finish = async (source: WalletSetupSource) => { const { result } = renderHook(() => useFinishWalletSetup()); @@ -58,7 +61,11 @@ describe("useFinishWalletSetup", () => { beforeEach(() => { jest.clearAllMocks(); mockBiometryType = null; - useLoginDataStore.setState({ mnemonicPhrase: PHRASE, password: PASSWORD }); + useLoginDataStore.setState({ + mnemonicPhrase: PHRASE, + secretKey: null, + password: PASSWORD, + }); useWalletSetupOverlayStore.setState( useWalletSetupOverlayStore.getInitialState(), true, @@ -94,6 +101,40 @@ describe("useFinishWalletSetup", () => { expect(mockSignUp).not.toHaveBeenCalled(); }); + it("imports from the secret key when Import Wallet kept one", async () => { + useLoginDataStore.setState({ + mnemonicPhrase: null, + secretKey: SECRET_KEY, + }); + mockImportWalletFromSecretKey.mockResolvedValue(true); + + await finish(BiometricsSource.IMPORT_WALLET); + + expect(mockImportWalletFromSecretKey).toHaveBeenCalledWith({ + secretKey: SECRET_KEY, + password: PASSWORD, + }); + expect(mockImportWallet).not.toHaveBeenCalled(); + expect(mockSignUp).not.toHaveBeenCalled(); + expect(useLoginDataStore.getState()).toMatchObject({ + secretKey: null, + password: null, + }); + }); + + it("never signs up from a secret key", async () => { + useLoginDataStore.setState({ + mnemonicPhrase: null, + secretKey: SECRET_KEY, + }); + + await finish(BiometricsSource.ONBOARDING); + + expect(mockSignUp).not.toHaveBeenCalled(); + expect(mockImportWalletFromSecretKey).not.toHaveBeenCalled(); + expect(mockNotifySetupFailed).toHaveBeenCalledTimes(1); + }); + it("keeps the phrase and password after a failure, so the user can retry", async () => { mockSignUp.mockResolvedValue(false); diff --git a/src/components/RecoveryPhraseField.tsx b/src/components/RecoveryPhraseField.tsx index e7fce40b9..0a272ef56 100644 --- a/src/components/RecoveryPhraseField.tsx +++ b/src/components/RecoveryPhraseField.tsx @@ -3,7 +3,10 @@ import { RecoveryPhraseInput } from "components/RecoveryPhraseInput"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; import { logger } from "config/logger"; -import { normalizeAndTrimRecoveryPhrase } from "helpers/recoveryPhrase"; +import { + normalizeAndTrimRecoveryPhrase, + normalizeImportSecretInput, +} from "helpers/recoveryPhrase"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import { useSecureClipboard } from "hooks/useSecureClipboard"; @@ -24,6 +27,11 @@ interface RecoveryPhraseFieldProps { error?: string | null; /** The field's text input, for a screen that focuses it itself. */ ref?: React.Ref; + /** + * Keeps typed and pasted text's case, for a field that also takes a + * secret key. See RecoveryPhraseInput. + */ + preserveCase?: boolean; testID?: string; } @@ -50,6 +58,7 @@ export const RecoveryPhraseField: React.FC = ({ placeholder, error, ref, + preserveCase = false, testID, }) => { const { t } = useAppTranslation(); @@ -69,7 +78,9 @@ export const RecoveryPhraseField: React.FC = ({ const handlePaste = () => { getClipboardText() .then((clipboardText) => { - const phrase = normalizeAndTrimRecoveryPhrase(clipboardText); + const phrase = preserveCase + ? normalizeImportSecretInput(clipboardText, { trim: true }) + : normalizeAndTrimRecoveryPhrase(clipboardText); if (!phrase) return undefined; onChangeText(phrase); @@ -105,6 +116,7 @@ export const RecoveryPhraseField: React.FC = ({ setValue={onChangeText} contextMenuHidden showMasked={isMasked} + preserveCase={preserveCase} isError={!!error} ref={ref} onFocus={() => setIsFocused(true)} diff --git a/src/components/RecoveryPhraseInput.tsx b/src/components/RecoveryPhraseInput.tsx index cfa892b1e..6b5d90616 100644 --- a/src/components/RecoveryPhraseInput.tsx +++ b/src/components/RecoveryPhraseInput.tsx @@ -1,6 +1,7 @@ import { Textarea, TextareaProps } from "components/sds/Textarea"; import { normalizeAndTrimRecoveryPhrase, + normalizeImportSecretInput, normalizeRecoveryPhrase, } from "helpers/recoveryPhrase"; import React, { useState, useEffect, useRef } from "react"; @@ -10,6 +11,12 @@ export interface RecoveryPhraseInputProps value: string; showMasked?: boolean; setValue: (text: string) => void; + /** + * Keeps the text's case and accents, collapsing only its spacing, for a + * field that may hold a case-sensitive secret key. Off, the text is + * normalized as a recovery phrase. + */ + preserveCase?: boolean; } interface TextRange { @@ -97,6 +104,7 @@ export const RecoveryPhraseInput: React.FC = ({ onSelectionChange, onKeyPress, setValue, + preserveCase = false, testID, ...restProps }) => { @@ -126,9 +134,14 @@ export const RecoveryPhraseInput: React.FC = ({ actualValue.slice(edit.end); // Into an empty field, a pasted phrase loses its outer spaces. After // that, a space typed after a word is kept for the next one. - const newValue = actualValue - ? normalizeRecoveryPhrase(edited) - : normalizeAndTrimRecoveryPhrase(edited); + let newValue: string; + if (preserveCase) { + newValue = normalizeImportSecretInput(edited, { trim: !actualValue }); + } else { + newValue = actualValue + ? normalizeRecoveryPhrase(edited) + : normalizeAndTrimRecoveryPhrase(edited); + } setActualValue(newValue); setValue(newValue); diff --git a/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx b/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx index abc90bd8b..18b7dc775 100644 --- a/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx +++ b/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx @@ -29,7 +29,11 @@ import { pxValue } from "helpers/dimensions"; import useAppTranslation from "hooks/useAppTranslation"; import { useBiometrics } from "hooks/useBiometrics"; import useColors from "hooks/useColors"; -import { walletSetupFlowFor } from "hooks/useFinishWalletSetup"; +import { + hasWalletSetupSecret, + runWalletSetup, + walletSetupFlowFor, +} from "hooks/useFinishWalletSetup"; import { useSetupFailedToast } from "hooks/useSetupFailedToast"; import useTheme from "hooks/useTheme"; import React, { useCallback, useMemo, useState } from "react"; @@ -179,11 +183,13 @@ export const BiometricsOnboardingScreen: React.FC< isLoading, signUp, importWallet, + importWalletFromSecretKey, enableBiometrics: enableBiometricsAction, } = useAuthenticationStore(); const { setIsBiometricsEnabled, biometryType } = useBiometrics(); const { themeColors } = useColors(); - const { mnemonicPhrase, password, clearLoginData } = useLoginDataStore(); + const { mnemonicPhrase, secretKey, password, clearLoginData } = + useLoginDataStore(); const navigation = useNavigation>(); const [isProcessing, setIsProcessing] = useState(false); @@ -213,11 +219,14 @@ export const BiometricsOnboardingScreen: React.FC< return; } - if (!mnemonicPhrase || !password) { + if ( + !hasWalletSetupSecret(source, { mnemonicPhrase, secretKey }) || + !password + ) { logger.error( "BiometricsOnboardingScreen", "Missing mnemonic phrase", - "mnemonicPhrase or password is missing", + "mnemonicPhrase, secretKey or password is missing", ); return; } @@ -232,19 +241,15 @@ export const BiometricsOnboardingScreen: React.FC< "true", ); - // Use importWallet for import flow, signUp for onboarding flow. - // These return false (rather than throwing) on failure — bail out so - // we don't clear login data / enable biometrics on a failed setup. - const success = - source === BiometricsSource.IMPORT_WALLET - ? await importWallet({ - mnemonicPhrase, - password: biometricPassword ?? password, - }) - : await signUp({ - mnemonicPhrase, - password: biometricPassword ?? password, - }); + // Import (phrase or secret key) or sign up. These return false + // (rather than throwing) on failure — bail out so we don't clear + // login data / enable biometrics on a failed setup. + const success = await runWalletSetup( + source, + { mnemonicPhrase, secretKey }, + biometricPassword ?? password, + { signUp, importWallet, importWalletFromSecretKey }, + ); if (!success) { throw new Error("Wallet setup failed during biometric enable"); @@ -272,9 +277,11 @@ export const BiometricsOnboardingScreen: React.FC< setIsBiometricsEnabled, signUp, importWallet, + importWalletFromSecretKey, enableBiometricsAction, navigation, mnemonicPhrase, + secretKey, password, clearLoginData, notifySetupFailed, @@ -293,25 +300,30 @@ export const BiometricsOnboardingScreen: React.FC< return; } - if (!mnemonicPhrase || !password) { + if ( + !hasWalletSetupSecret(source, { mnemonicPhrase, secretKey }) || + !password + ) { logger.error( "BiometricsOnboardingScreen", "Missing mnemonic phrase or password", - "mnemonicPhrase or password is missing", + "mnemonicPhrase, secretKey or password is missing", ); return; } try { - // Use importWallet for import flow, signUp for onboarding flow. - // Both return false (rather than throwing) on failure. The wallet setup - // screen covers the wait. + // Import (phrase or secret key) or sign up. Both return false + // (rather than throwing) on failure. The wallet setup screen covers the + // wait. const success = await runWithWalletSetupOverlay( walletSetupFlowFor(source), () => - source === BiometricsSource.IMPORT_WALLET - ? importWallet({ mnemonicPhrase, password }) - : signUp({ mnemonicPhrase, password }), + runWalletSetup(source, { mnemonicPhrase, secretKey }, password, { + signUp, + importWallet, + importWalletFromSecretKey, + }), ); if (success) { @@ -334,8 +346,10 @@ export const BiometricsOnboardingScreen: React.FC< route.params, signUp, importWallet, + importWalletFromSecretKey, navigation, mnemonicPhrase, + secretKey, password, clearLoginData, notifySetupFailed, diff --git a/src/components/screens/ChoosePasswordScreen.tsx b/src/components/screens/ChoosePasswordScreen.tsx index ea248d915..5b2704817 100644 --- a/src/components/screens/ChoosePasswordScreen.tsx +++ b/src/components/screens/ChoosePasswordScreen.tsx @@ -29,9 +29,9 @@ type ChoosePasswordScreenProps = NativeStackScreenProps< * (getSetPasswordScreenOptions), and Next keeps the password in memory and * opens the recovery phrase. Importing, it slides in with a back arrow to * Import Wallet, and the button says "Import wallet" throughout. The user - * also acknowledges that an imported phrase can't be recovered, and the - * button stays disabled until they have; then it finishes the setup with the - * phrase Import Wallet kept in memory. + * also acknowledges that an imported phrase or secret key can't be recovered, + * and the button stays disabled until they have; then it finishes the setup + * with the phrase or key Import Wallet kept in memory. * * Design: create ?node-id=370-8343, import ?node-id=2091-40291 (light); * colors from 760-36962 and 2091-41902 (dark). @@ -43,6 +43,9 @@ export const ChoosePasswordScreen: React.FC = ({ const isImporting = route.params?.isImporting ?? false; const { t } = useAppTranslation(); const setStoredPassword = useLoginDataStore((state) => state.setPassword); + const isImportingSecretKey = useLoginDataStore( + (state) => isImporting && !!state.secretKey, + ); const isFinishing = useAuthenticationStore((state) => state.isLoading); const finishWalletSetup = useFinishWalletSetup(); const passwordRef = useRef(null); @@ -103,9 +106,10 @@ export const ChoosePasswordScreen: React.FC = ({ return; } - // Import Wallet keeps the phrase in memory before opening this screen. - // Without it there is nothing to import, so ask for it again. - if (!useLoginDataStore.getState().mnemonicPhrase) { + // Import Wallet keeps the phrase or key in memory before opening this + // screen. Without either there is nothing to import, so ask again. + const { mnemonicPhrase, secretKey } = useLoginDataStore.getState(); + if (!mnemonicPhrase && !secretKey) { navigation.goBack(); return; } @@ -180,7 +184,11 @@ export const ChoosePasswordScreen: React.FC = ({ testID="import-acknowledgement-checkbox" checked={hasAcknowledged} onChange={handleAcknowledgeChange} - label={t("choosePasswordScreen.importAcknowledgement")} + label={ + isImportingSecretKey + ? t("choosePasswordScreen.importSecretKeyAcknowledgement") + : t("choosePasswordScreen.importAcknowledgement") + } /> )} diff --git a/src/components/screens/ImportWalletScreen.tsx b/src/components/screens/ImportWalletScreen.tsx index 168ccb90a..daa1440aa 100644 --- a/src/components/screens/ImportWalletScreen.tsx +++ b/src/components/screens/ImportWalletScreen.tsx @@ -2,10 +2,9 @@ import { useFocusEffect } from "@react-navigation/native"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { ImportRecoveryPhraseTemplate } from "components/templates/ImportRecoveryPhraseTemplate"; import { AUTH_STACK_ROUTES, AuthStackParamList } from "config/routes"; -import { useAuthenticationStore } from "ducks/auth"; import { useLoginDataStore } from "ducks/loginData"; import { waitForKeyboardDismiss } from "helpers/keyboard"; -import { normalizeAndTrimRecoveryPhrase } from "helpers/recoveryPhrase"; +import { parseImportSecret } from "helpers/parseImportSecret"; import useAppTranslation from "hooks/useAppTranslation"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { TextInput } from "react-native"; @@ -16,9 +15,10 @@ type ImportWalletScreenProps = NativeStackScreenProps< >; /** - * Import Wallet, the first step of importing a wallet: the recovery phrase, - * then Set Password. Next checks the phrase and keeps it in memory for Set - * Password, which finishes the import. + * Import Wallet, the first step of importing a wallet: a recovery phrase or a + * secret key, then Set Password. Next works out which one the field holds and + * keeps it in memory for Set Password, which finishes the import. A secret key + * keeps its case, so the field doesn't lowercase what's typed or pasted. * * Design: empty ?node-id=2091-40745, masked 2091-40994, shown 3514-104321 * (light); colors from 2091-41871 (dark). @@ -27,15 +27,13 @@ export const ImportWalletScreen: React.FC = ({ navigation, }) => { const { t } = useAppTranslation(); - const { error, clearError, verifyMnemonicPhrase } = useAuthenticationStore(); - const setMnemonicPhrase = useLoginDataStore( - (state) => state.setMnemonicPhrase, - ); - const [phrase, setPhrase] = useState(""); - const [isValidating, setIsValidating] = useState(false); + const { clearLoginData, setMnemonicPhrase, setSecretKey } = + useLoginDataStore(); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); const inputRef = useRef(null); - // Set from Next until the phrase fails its check or the screen has moved - // on, so a second tap meanwhile doesn't check it or move on twice. + // Set from Next until the screen has moved on, so a second tap meanwhile + // doesn't move on twice. const isSubmittingRef = useRef(false); // Raises the keyboard once the screen has slid in: raising it while the @@ -53,57 +51,48 @@ export const ImportWalletScreen: React.FC = ({ }, [navigation]); // Every visit starts fresh. Coming back from Set Password clears the - // phrase, its error and the copy kept in memory, ready for a new paste. + // field, its error and the copy kept in memory, ready for a new paste. useFocusEffect( useCallback(() => { - setPhrase(""); - clearError(); - setMnemonicPhrase(null); - }, [clearError, setMnemonicPhrase]), + setInput(""); + setError(null); + clearLoginData(); + }, [clearLoginData]), ); - const handleChangePhrase = useCallback( - (text: string) => { - setPhrase(text); - // The error was about the phrase as it was; an edited one hasn't been - // checked yet. - if (error) clearError(); - }, - [clearError, error], - ); + const handleChangeInput = useCallback((text: string) => { + setInput(text); + // The error was about the text as it was; edited, it hasn't been checked. + setError(null); + }, []); const handleNext = useCallback(() => { - if (!phrase || isSubmittingRef.current) return; + if (!input || isSubmittingRef.current) return; + + // Checking a phrase's wordlist and checksum is cheap, so it runs before + // the keyboard goes and an invalid one leaves the keyboard up to fix it. + const parsed = parseImportSecret(input); + if (parsed.type === "invalid") { + setError(t("importWalletScreen.invalidInput")); + return; + } isSubmittingRef.current = true; - // Typing word by word can leave a space after the last word, which the - // masked field hides and which would fail the check. - const normalizedPhrase = normalizeAndTrimRecoveryPhrase(phrase); + if (parsed.type === "phrase") { + setMnemonicPhrase(parsed.mnemonicPhrase); + } else { + setSecretKey(parsed.secretKey); + } - // The keyboard goes first, so the button has settled at the bottom - // before its spinner shows. Set Password then comes in with the keyboard - // down; arriving while it's still up leaves the acknowledgement and the - // button under it. + // Set Password comes in with the keyboard down; arriving while it's + // still up leaves the acknowledgement and the button under it. waitForKeyboardDismiss().then(() => { - setIsValidating(true); - - // Checking the phrase derives its seed synchronously and blocks the JS - // thread for a moment, so let the button's spinner paint first. - setTimeout(() => { - const isValid = verifyMnemonicPhrase(normalizedPhrase); - isSubmittingRef.current = false; - setIsValidating(false); - - // An invalid phrase leaves the store's error under the field. - if (!isValid) return; - - setMnemonicPhrase(normalizedPhrase); - navigation.navigate(AUTH_STACK_ROUTES.CHOOSE_PASSWORD_SCREEN, { - isImporting: true, - }); - }, 0); + isSubmittingRef.current = false; + navigation.navigate(AUTH_STACK_ROUTES.CHOOSE_PASSWORD_SCREEN, { + isImporting: true, + }); }); - }, [navigation, phrase, setMnemonicPhrase, verifyMnemonicPhrase]); + }, [input, navigation, setMnemonicPhrase, setSecretKey, t]); return ( = ({ description={t("importWalletScreen.description")} placeholder={t("importWalletScreen.textAreaPlaceholder")} submitButtonText={t("onboarding.next")} - phrase={phrase} - onChangePhrase={handleChangePhrase} + phrase={input} + onChangePhrase={handleChangeInput} onSubmit={handleNext} error={error} - isSubmitting={isValidating} inputRef={inputRef} + preserveCase /> ); }; diff --git a/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx index dae64fffd..02934762b 100644 --- a/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx +++ b/src/components/screens/SettingsScreen/SecurityScreen/SecurityScreen.tsx @@ -7,6 +7,7 @@ import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; import useAppTranslation from "hooks/useAppTranslation"; import { useBiometrics } from "hooks/useBiometrics"; import useColors from "hooks/useColors"; +import { useHasRecoveryPhrase } from "hooks/useHasRecoveryPhrase"; import React, { useCallback } from "react"; import { View } from "react-native"; import { BIOMETRY_TYPE } from "react-native-keychain"; @@ -20,6 +21,8 @@ const SecurityScreen: React.FC = ({ navigation }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); const { biometryType } = useBiometrics(); + // A wallet onboarded from a secret key has no phrase to show. + const hasPhrase = useHasRecoveryPhrase(); const biometryTitle: Record = { [BIOMETRY_TYPE.FACE_ID]: t("securityScreen.faceId.title"), @@ -37,8 +40,9 @@ const SecurityScreen: React.FC = ({ navigation }) => { return ; }, [biometryType, themeColors]); - const listItems = [ - { + const listItems = []; + if (hasPhrase) { + listItems.push({ icon: , title: t("securityScreen.showRecoveryPhrase"), titleColor: themeColors.text.primary, @@ -48,8 +52,8 @@ const SecurityScreen: React.FC = ({ navigation }) => { ), testID: "show-recovery-phrase-button", - }, - ]; + }); + } if (biometryType) { listItems.push({ icon: getBiometryIcon(), diff --git a/src/components/screens/SettingsScreen/SecurityScreen/ShowRecoveryPhraseScreen/ShowRecoveryPhraseScreen.tsx b/src/components/screens/SettingsScreen/SecurityScreen/ShowRecoveryPhraseScreen/ShowRecoveryPhraseScreen.tsx index 5506cc2a0..37dbc9530 100644 --- a/src/components/screens/SettingsScreen/SecurityScreen/ShowRecoveryPhraseScreen/ShowRecoveryPhraseScreen.tsx +++ b/src/components/screens/SettingsScreen/SecurityScreen/ShowRecoveryPhraseScreen/ShowRecoveryPhraseScreen.tsx @@ -7,13 +7,16 @@ import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; import { LoginType } from "config/constants"; +import { logger } from "config/logger"; import { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes"; import { getActiveMnemonicPhrase, useAuthenticationStore } from "ducks/auth"; import { useLoginDataStore } from "ducks/loginData"; +import { hasRecoveryPhrase } from "helpers/hasRecoveryPhrase"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { View, ScrollView, StyleSheet } from "react-native"; +import { getSecretSources } from "services/auth/secretSources"; type ShowRecoveryPhraseScreenProps = NativeStackScreenProps< SettingsStackParamList, @@ -40,6 +43,30 @@ const ShowRecoveryPhraseScreen: React.FC = ({ const { getKeyFromKeyManager, signInMethod } = useAuthenticationStore(); const { setMnemonicPhrase } = useLoginDataStore(); + // Security hides the way here for a wallet with no phrase (one onboarded + // from a secret key); anything that still lands here goes back. + useEffect(() => { + let isActive = true; + + getSecretSources() + .then((map) => { + if (isActive && !hasRecoveryPhrase(Object.values(map))) { + navigation.goBack(); + } + }) + .catch((sourcesError: unknown) => { + logger.error( + "ShowRecoveryPhraseScreen", + "Failed to read secret sources", + sourcesError, + ); + }); + + return () => { + isActive = false; + }; + }, [navigation]); + const showRecoveryPhraseAction = async (password: string) => { try { // Verifies the password against the active keystore, then shows the diff --git a/src/components/templates/ImportRecoveryPhraseTemplate.tsx b/src/components/templates/ImportRecoveryPhraseTemplate.tsx index 3f54d65e4..94437b8f7 100644 --- a/src/components/templates/ImportRecoveryPhraseTemplate.tsx +++ b/src/components/templates/ImportRecoveryPhraseTemplate.tsx @@ -15,6 +15,8 @@ export interface ImportRecoveryPhraseTemplateProps { isSubmitting?: boolean; /** The field's text input, for the screen to focus once it has slid in. */ inputRef?: React.Ref; + /** Keeps the field's case, for a screen that also takes a secret key. */ + preserveCase?: boolean; testID?: string; } @@ -39,6 +41,7 @@ export const ImportRecoveryPhraseTemplate: React.FC< error, isSubmitting, inputRef, + preserveCase, testID, }) => ( diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 8071d041b..7806afde9 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -183,6 +183,18 @@ interface ImportWalletParams { password: string; } +/** + * Parameters for importWalletFromSecretKey function + * + * @interface ImportWalletFromSecretKeyParams + * @property {string} secretKey - The Stellar secret key to onboard from + * @property {string} password - User's password for encrypting the imported wallet + */ +interface ImportWalletFromSecretKeyParams { + secretKey: string; + password: string; +} + /** * Parameters for storeAccount function * @@ -372,6 +384,10 @@ interface AuthActions { signUp: (params: SignUpParams) => Promise; signIn: (params: SignInParams) => Promise; importWallet: (params: ImportWalletParams) => Promise; + /** Onboards a wallet from a secret key, replacing whatever was stored. */ + importWalletFromSecretKey: ( + params: ImportWalletFromSecretKeyParams, + ) => Promise; verifyMnemonicPhrase: (mnemonicPhrase: string) => boolean; importSecretKey: (params: ImportSecretKeyParams) => Promise; linkSources: () => void; @@ -2518,6 +2534,71 @@ const importWallet = async ({ } }; +/** + * Onboards a wallet from a single secret key: one imported account rooted in + * its own SECRET_KEY source, with no phrase. The key is checked before + * anything is wiped, so an invalid one leaves the device as it was. A failure + * after the wipe wipes again, leaving a fresh install the user can retry from. + * + * @param {ImportWalletFromSecretKeyParams} params - The secret key and password + * @returns {Promise} + */ +const importWalletFromSecretKey = async ({ + secretKey, + password, +}: ImportWalletFromSecretKeyParams): Promise => { + let keyPair: KeyPair; + let sourceId: string; + + // Nothing about the error is logged or tracked: it could carry the key. + try { + keyPair = { + publicKey: Keypair.fromSecret(secretKey).publicKey(), + privateKey: secretKey, + }; + sourceId = await getSecretKeySourceId(secretKey); + } catch { + analytics.track(AnalyticsEvent.RECOVER_ACCOUNT_FAIL, { + recovery_method: "secret_key", + reason_code: "invalid_secret_key", + }); + throw new Error(t("authStore.error.invalidSecretKey")); + } + + try { + clearAccountData(); + await clearAllData(); + + await storeAccount({ + sourceId, + password, + keyPair, + importedFromSecretKey: true, + }); + await addSecretSource({ + id: sourceId, + type: SecretSourceType.SECRET_KEY, + }); + + analytics.track(AnalyticsEvent.RECOVER_ACCOUNT_SUCCESS, { + recovery_method: "secret_key", + }); + } catch (error) { + // Scrubbed: the message can carry uncontrolled native text. + const importFailureMessage = + error instanceof Error ? error.message : String(error); + analytics.track(AnalyticsEvent.RECOVER_ACCOUNT_FAIL, { + recovery_method: "secret_key", + reason_code: scrubStrKeys(importFailureMessage) ?? importFailureMessage, + }); + // The previous wallet is already gone, so drop the partial writes too. + clearAccountData(); + await clearAllData(); + + throw new Error(t("authStore.error.failedToImportWallet")); + } +}; + /** * Updates the temporary store with a private key for a specific account * @@ -3271,6 +3352,7 @@ export const getUserFacingError = ( const safeMessages = new Set([ t("authStore.error.invalidPassword"), t("authStore.error.invalidMnemonicPhrase"), + t("authStore.error.invalidSecretKey"), t("authStore.error.accountAlreadyExists"), t("authStore.error.accountNotFound"), t("authStore.error.accountListNotFound"), @@ -4111,6 +4193,51 @@ export const useAuthenticationStore = create()((set, get) => ({ } }, + /** + * Onboards a wallet from a secret key + * + * @param {ImportWalletFromSecretKeyParams} params - The secret key and password + * @returns {Promise} `true` on success, `false` on failure + */ + importWalletFromSecretKey: async (params): Promise => { + set({ isLoading: true, error: null }); + + try { + // Replaces the wallet: reset auto-lock to the default before the hash + // key is generated so it doesn't inherit the previous timer. + await withAccountMutationLock(async () => { + await resetAutoLockForNewWallet(); + await importWalletFromSecretKey(params); + }); + set({ + ...initialState, + navigationRef: get().navigationRef, + authStatus: AUTH_STATUS.AUTHENTICATED, + isLoading: false, + }); + + get().fetchActiveAccount(); + // Link the imported key's source. + get().linkSources(); + return true; + } catch (error) { + // The module function throws only its own messages, never the key's. + logger.error( + "useAuthenticationStore.importWalletFromSecretKey", + "Import wallet from secret key failed", + error, + ); + set({ + error: getUserFacingError( + error, + "authStore.error.failedToImportWallet", + ), + isLoading: false, + }); + return false; + } + }, + /** * Gets the current authentication status. * diff --git a/src/ducks/loginData.ts b/src/ducks/loginData.ts index 5e7fe9c75..9f0f40261 100644 --- a/src/ducks/loginData.ts +++ b/src/ducks/loginData.ts @@ -4,23 +4,29 @@ import { create } from "zustand"; * Login Data State Interface * * Defines the structure of the login data state store using Zustand. - * This store manages sensitive login data (password and mnemonic phrase) in memory only. + * This store manages sensitive login data (password, mnemonic phrase and an + * onboarding secret key) in memory only. * Data is never persisted to disk or navigation state for security. * + * At most one of `mnemonicPhrase` and `secretKey` is set: setting one clears + * the other, so the wallet setup never has to pick between them. + * * @interface LoginDataState * @property {string | null} mnemonicPhrase - The mnemonic phrase (null if not set) + * @property {string | null} secretKey - The secret key Import Wallet was given (null if not set) * @property {string | null} password - The password (null if not set) - * @property {Function} setMnemonicPhrase - Function to set the mnemonic phrase - * @property {Function} clearMnemonicPhrase - Function to clear the mnemonic phrase + * @property {Function} setMnemonicPhrase - Sets the mnemonic phrase and clears the secret key + * @property {Function} setSecretKey - Sets the secret key and clears the mnemonic phrase * @property {Function} setPassword - Function to set the password - * @property {Function} clearPassword - Function to clear the password - * @property {Function} clearAll - Function to clear all login data + * @property {Function} clearLoginData - Function to clear all login data */ interface LoginDataState { mnemonicPhrase: string | null; + secretKey: string | null; password: string | null; setMnemonicPhrase: (phrase: string | null) => void; + setSecretKey: (secretKey: string | null) => void; setPassword: (password: string | null) => void; clearLoginData: () => void; } @@ -48,15 +54,24 @@ interface LoginDataState { * const { mnemonicPhrase, password } = useLoginDataStore(); * * // Clear all data - * const { clearAll } = useLoginDataStore(); - * clearAll(); + * const { clearLoginData } = useLoginDataStore(); + * clearLoginData(); */ export const useLoginDataStore = create((set) => ({ mnemonicPhrase: null, + secretKey: null, password: null, setMnemonicPhrase: (phrase: string | null) => { - set({ mnemonicPhrase: phrase }); + set( + phrase + ? { mnemonicPhrase: phrase, secretKey: null } + : { mnemonicPhrase: null }, + ); + }, + + setSecretKey: (secretKey: string | null) => { + set(secretKey ? { secretKey, mnemonicPhrase: null } : { secretKey: null }); }, setPassword: (password: string | null) => { @@ -64,6 +79,6 @@ export const useLoginDataStore = create((set) => ({ }, clearLoginData: () => { - set({ mnemonicPhrase: null, password: null }); + set({ mnemonicPhrase: null, secretKey: null, password: null }); }, })); diff --git a/src/helpers/hasRecoveryPhrase.ts b/src/helpers/hasRecoveryPhrase.ts new file mode 100644 index 000000000..0985653c5 --- /dev/null +++ b/src/helpers/hasRecoveryPhrase.ts @@ -0,0 +1,11 @@ +import { SecretSource, SecretSourceType } from "config/types"; + +/** + * Whether the wallet holds a recovery phrase to show. A wallet onboarded from + * a secret key holds only SECRET_KEY sources and has none. An empty list + * means the sources haven't loaded (or predate source records), so it stays + * permissive. + */ +export const hasRecoveryPhrase = (sources: SecretSource[]): boolean => + sources.length === 0 || + sources.some(({ type }) => type === SecretSourceType.PHRASE); diff --git a/src/helpers/parseImportSecret.ts b/src/helpers/parseImportSecret.ts new file mode 100644 index 000000000..65d1b12f8 --- /dev/null +++ b/src/helpers/parseImportSecret.ts @@ -0,0 +1,29 @@ +import { StrKey } from "@stellar/stellar-sdk"; +import { + isValidImportMnemonic, + normalizeAndTrimRecoveryPhrase, +} from "helpers/recoveryPhrase"; + +export type ParsedImportSecret = + | { type: "phrase"; mnemonicPhrase: string } + | { type: "secret_key"; secretKey: string } + | { type: "invalid" }; + +/** + * Classifies what Import Wallet was given: a Stellar secret key, a 12 or + * 24-word recovery phrase, or neither. The secret key is checked first and + * keeps its case; a phrase comes back normalized. + */ +export const parseImportSecret = (input: string): ParsedImportSecret => { + const trimmed = input.trim(); + if (StrKey.isValidEd25519SecretSeed(trimmed)) { + return { type: "secret_key", secretKey: trimmed }; + } + if (isValidImportMnemonic(input)) { + return { + type: "phrase", + mnemonicPhrase: normalizeAndTrimRecoveryPhrase(input), + }; + } + return { type: "invalid" }; +}; diff --git a/src/helpers/recoveryPhrase.ts b/src/helpers/recoveryPhrase.ts index 465b0cf2e..318cb5254 100644 --- a/src/helpers/recoveryPhrase.ts +++ b/src/helpers/recoveryPhrase.ts @@ -10,6 +10,19 @@ export const normalizeRecoveryPhrase = (text: string): string => export const normalizeAndTrimRecoveryPhrase = (text: string): string => normalizeRecoveryPhrase(text).replace(/\n/g, " ").trim(); +/** + * Normalizes typed or pasted text for a field that may hold either a phrase + * or a secret key. Only spacing is collapsed: a secret key is case-sensitive, + * so it must not be lowercased the way a phrase is. Trimmed when `trim`. + */ +export const normalizeImportSecretInput = ( + text: string, + { trim = false }: { trim?: boolean } = {}, +): string => { + const collapsed = text.replace(/\s+/g, " "); + return trim ? collapsed.trim() : collapsed; +}; + /** * True when the phrase, once normalized, is 12 or 24 words and passes the * BIP-39 English wordlist and checksum. Lighter than deriving the wallet, so diff --git a/src/hooks/useFinishWalletSetup.ts b/src/hooks/useFinishWalletSetup.ts index b9995b60e..5259be7b9 100644 --- a/src/hooks/useFinishWalletSetup.ts +++ b/src/hooks/useFinishWalletSetup.ts @@ -13,7 +13,7 @@ import { useBiometrics } from "hooks/useBiometrics"; import { useSetupFailedToast } from "hooks/useSetupFailedToast"; import { useCallback, useRef } from "react"; -/** Logged, and the Sentry issue's title, when the phrase or password is gone. */ +/** Logged, and the Sentry issue's title, when the secret or password is gone. */ const MISSING_LOGIN_DATA = "Missing the phrase or the password"; /** Which wallet the last onboarding step finishes: a new one or an import. */ @@ -27,6 +27,67 @@ export const walletSetupFlowFor = ( ): WalletSetupFlow => source === BiometricsSource.IMPORT_WALLET ? "import" : "create"; +/** What onboarding kept in memory to set a wallet up from. */ +export interface WalletSetupSecret { + mnemonicPhrase: string | null; + secretKey: string | null; +} + +/** The auth store actions a wallet setup can end in. */ +export interface WalletSetupActions { + signUp: (params: { + mnemonicPhrase: string; + password: string; + }) => Promise; + importWallet: (params: { + mnemonicPhrase: string; + password: string; + }) => Promise; + importWalletFromSecretKey: (params: { + secretKey: string; + password: string; + }) => Promise; +} + +/** + * Whether `secret` holds what `source` needs: creating needs the phrase, + * importing needs a phrase or a secret key. + */ +export const hasWalletSetupSecret = ( + source: WalletSetupSource, + { mnemonicPhrase, secretKey }: WalletSetupSecret, +): boolean => + source === BiometricsSource.IMPORT_WALLET + ? !!mnemonicPhrase || !!secretKey + : !!mnemonicPhrase; + +/** + * Creates or imports the wallet from what onboarding kept in memory: a new + * phrase signs up, an imported phrase recovers its wallet and an imported + * secret key onboards from that key. Resolves `false` on failure, as the + * store actions do, including when `secret` lacks what `source` needs. + */ +export const runWalletSetup = ( + source: WalletSetupSource, + { mnemonicPhrase, secretKey }: WalletSetupSecret, + password: string, + actions: WalletSetupActions, +): Promise => { + if (source === BiometricsSource.IMPORT_WALLET) { + if (secretKey) { + return actions.importWalletFromSecretKey({ secretKey, password }); + } + if (mnemonicPhrase) { + return actions.importWallet({ mnemonicPhrase, password }); + } + return Promise.resolve(false); + } + + return mnemonicPhrase + ? actions.signUp({ mnemonicPhrase, password }) + : Promise.resolve(false); +}; + /** * Finishes creating or importing a wallet, for the last onboarding step: * Recovery Phrase when creating, Set Password when importing. @@ -34,18 +95,22 @@ export const walletSetupFlowFor = ( * With biometrics, it saves the password for them and opens Biometrics, * which creates the wallet once the user enables or skips them. Without, * it creates or imports the wallet here, behind the wallet setup screen, - * and clears the in-memory phrase and password. A failure takes the screen + * and clears the in-memory phrase or key and the password. A failure takes the screen * away, shows the setup-failed toast and keeps both, so the user can try * again. * - * The phrase and password are read from the store when called, not captured + * The phrase, key and password are read from the store when called, not captured * at render, so the step never acts on stale values. */ export const useFinishWalletSetup = () => { const navigation = useNavigation>(); - const { signUp, importWallet, storeBiometricPassword } = - useAuthenticationStore(); + const { + signUp, + importWallet, + importWalletFromSecretKey, + storeBiometricPassword, + } = useAuthenticationStore(); const { biometryType } = useBiometrics(); const notifySetupFailed = useSetupFailedToast(); // A second tap before the first finishes would set the wallet up twice, @@ -58,10 +123,11 @@ export const useFinishWalletSetup = () => { isFinishingRef.current = true; try { - const { mnemonicPhrase, password, clearLoginData } = + const { mnemonicPhrase, secretKey, password, clearLoginData } = useLoginDataStore.getState(); + const secret = { mnemonicPhrase, secretKey }; - if (!mnemonicPhrase || !password) { + if (!hasWalletSetupSecret(source, secret) || !password) { logger.error( "useFinishWalletSetup", MISSING_LOGIN_DATA, @@ -83,9 +149,11 @@ export const useFinishWalletSetup = () => { const succeeded = await runWithWalletSetupOverlay( walletSetupFlowFor(source), () => - source === BiometricsSource.IMPORT_WALLET - ? importWallet({ mnemonicPhrase, password }) - : signUp({ mnemonicPhrase, password }), + runWalletSetup(source, secret, password, { + signUp, + importWallet, + importWalletFromSecretKey, + }), ); if (succeeded) { @@ -107,6 +175,7 @@ export const useFinishWalletSetup = () => { [ biometryType, importWallet, + importWalletFromSecretKey, navigation, notifySetupFailed, signUp, diff --git a/src/hooks/useHasRecoveryPhrase.ts b/src/hooks/useHasRecoveryPhrase.ts new file mode 100644 index 000000000..76ff80cef --- /dev/null +++ b/src/hooks/useHasRecoveryPhrase.ts @@ -0,0 +1,39 @@ +import { useFocusEffect } from "@react-navigation/native"; +import { logger } from "config/logger"; +import { hasRecoveryPhrase } from "helpers/hasRecoveryPhrase"; +import { useCallback, useState } from "react"; +import { getSecretSources } from "services/auth/secretSources"; + +/** + * Whether the wallet holds a recovery phrase, read from its secret sources + * each time the screen gains focus. True until the sources have loaded and + * when they can't be read, so a wallet with a phrase never loses the way to + * it; only a wallet whose sources hold no phrase reads false. + */ +export const useHasRecoveryPhrase = (): boolean => { + const [hasPhrase, setHasPhrase] = useState(true); + + useFocusEffect( + useCallback(() => { + let isActive = true; + + getSecretSources() + .then((map) => { + if (isActive) setHasPhrase(hasRecoveryPhrase(Object.values(map))); + }) + .catch((error: unknown) => { + logger.error( + "useHasRecoveryPhrase", + "Failed to read secret sources", + error, + ); + }); + + return () => { + isActive = false; + }; + }, []), + ); + + return hasPhrase; +}; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index fc222a8c6..fdd4afccf 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -213,6 +213,7 @@ "passwordTooLong": "Must be less than 2048 characters", "passwordsDontMatch": "Passwords don't match", "importAcknowledgement": "I understand imported recovery phrase can't be recovered and must be stored securely.", + "importSecretKeyAcknowledgement": "I understand an imported secret key can't be recovered and must be stored securely.", "importWalletButtonText": "Import wallet" }, "recoveryPhraseScreen": { @@ -265,8 +266,9 @@ }, "importWalletScreen": { "title": "Import Wallet", - "description": "Import your wallet with the 12 or 24-word recovery phrase", - "textAreaPlaceholder": "Enter your recovery phrase" + "description": "Import your wallet using a recovery phrase or secret key", + "textAreaPlaceholder": "Enter your recovery phrase or secret key", + "invalidInput": "Enter a valid recovery phrase or secret key" }, "importRecoveryPhraseScreen": { "title": "Import Recovery Phrase", @@ -489,6 +491,7 @@ "failedToLoadAccount": "Failed to load account", "invalidPassword": "Invalid password. Please try again.", "invalidMnemonicPhrase": "Invalid phrase - words must be in the correct order, lowercase, and separated by spaces", + "invalidSecretKey": "Invalid secret key", "failedToCreateAccount": "Failed to create account. Please try again.", "accountAlreadyExists": "Account already exists", "failedToImportSecretKey": "Failed to import secret key", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 21ba39569..19371e4bd 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -179,6 +179,7 @@ "passwordTooLong": "Deve ter menos de 2048 caracteres", "passwordsDontMatch": "As senhas não coincidem", "importAcknowledgement": "Entendo que a frase de recuperação importada não pode ser recuperada e deve ser guardada com segurança.", + "importSecretKeyAcknowledgement": "Entendo que a chave secreta importada não pode ser recuperada e deve ser guardada com segurança.", "importWalletButtonText": "Importar carteira" }, "recoveryPhraseScreen": { @@ -231,8 +232,9 @@ }, "importWalletScreen": { "title": "Importar Carteira", - "description": "Importe sua carteira com a frase de recuperação de 12 ou 24 palavras", - "textAreaPlaceholder": "Digite sua frase de recuperação" + "description": "Importe sua carteira usando uma frase de recuperação ou chave secreta", + "textAreaPlaceholder": "Digite sua frase de recuperação ou chave secreta", + "invalidInput": "Digite uma frase de recuperação ou chave secreta válida" }, "importRecoveryPhraseScreen": { "title": "Importar frase de recuperação", @@ -455,6 +457,7 @@ "failedToLoadAccount": "Falha ao carregar conta", "invalidPassword": "Senha inválida. Por favor, tente novamente.", "invalidMnemonicPhrase": "Frase inválida - as palavras devem estar na ordem correta, em minúsculas e separadas por espaços", + "invalidSecretKey": "Chave secreta inválida", "failedToCreateAccount": "Falha ao criar conta. Por favor, tente novamente.", "accountAlreadyExists": "Conta já existe", "failedToImportSecretKey": "Falha ao importar chave secreta", From d2afc948cf51fa8e3a2a6a8b56efbf7919797cd0 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 13 Nov 2026 10:54:17 -0700 Subject: [PATCH 08/14] feat(onboarding): hold the wallet setup text for at least 500ms Port of the minimum importing time from stellar/freighter#3091 (835b23a8). runWithWalletSetupOverlay waits at least SETTING_UP_MIN_MS (500ms) before turning ready or fading out, so a secret-key import doesn't flash "Importing your wallet...". The Biometrics screen tests run on fake timers so the wait stays inside each test. --- .../BiometricsEnableScreen.test.tsx | 19 +++++- __tests__/ducks/walletSetupOverlay.test.ts | 59 +++++++++++++++++-- src/ducks/walletSetupOverlay.ts | 20 ++++++- 3 files changed, 87 insertions(+), 11 deletions(-) diff --git a/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx b/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx index 0900acc15..7291c6dc7 100644 --- a/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx +++ b/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx @@ -1,7 +1,10 @@ import { act, fireEvent, waitFor, within } from "@testing-library/react-native"; import { BiometricsOnboardingScreen } from "components/screens/BiometricsEnableScreen/BiometricsEnableScreen"; import { BiometricsSource } from "config/constants"; -import { useWalletSetupOverlayStore } from "ducks/walletSetupOverlay"; +import { + SETTING_UP_MIN_MS, + useWalletSetupOverlayStore, +} from "ducks/walletSetupOverlay"; import { renderWithProviders } from "helpers/testUtils"; import React from "react"; import { BIOMETRY_TYPE } from "react-native-keychain"; @@ -87,9 +90,19 @@ const deferred = () => { }; describe("BiometricsOnboardingScreen", () => { + afterEach(async () => { + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + jest.useRealTimers(); + }); + beforeEach(() => { jest.clearAllMocks(); mockEnableBiometrics.mockReset(); + // Wallet setup holds its title for a minimum time; fake timers keep + // that wait inside each test. + jest.useFakeTimers(); mockBiometryType = BIOMETRY_TYPE.FACE_ID; mockLoginSecret = { mnemonicPhrase: "abandon ability able", @@ -341,7 +354,7 @@ describe("BiometricsOnboardingScreen", () => { expect(overlay().flow).toBe("create"); await settle(() => signUp.resolve(true)); - expect(overlay().phase).toBe("ready"); + await waitFor(() => expect(overlay().phase).toBe("ready")); }); it("shows Importing your wallet when Skip imports", async () => { @@ -424,6 +437,8 @@ describe("BiometricsOnboardingScreen", () => { unmount(); await settle(() => signUp.resolve(true)); + // The setting-up title's minimum time. + await act(() => jest.advanceTimersByTimeAsync(SETTING_UP_MIN_MS)); expect(overlay().phase).toBe("ready"); }); diff --git a/__tests__/ducks/walletSetupOverlay.test.ts b/__tests__/ducks/walletSetupOverlay.test.ts index de96db08e..6d5221383 100644 --- a/__tests__/ducks/walletSetupOverlay.test.ts +++ b/__tests__/ducks/walletSetupOverlay.test.ts @@ -1,4 +1,5 @@ import { + SETTING_UP_MIN_MS, runWithWalletSetupOverlay, useWalletSetupOverlayStore, } from "ducks/walletSetupOverlay"; @@ -112,6 +113,14 @@ describe("walletSetupOverlay", () => { }); describe("runWithWalletSetupOverlay", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + /** Lets setup's outcome land, then the minimum time pass. */ + const passMinimum = async () => { + await jest.advanceTimersByTimeAsync(SETTING_UP_MIN_MS); + }; + it("shows the screen while setup runs and turns ready when it resolves true", async () => { let finish: (value: boolean) => void = () => {}; const setup = () => @@ -123,6 +132,38 @@ describe("walletSetupOverlay", () => { expect(store().phase).toBe("settingUp"); expect(store().flow).toBe("create"); + finish(true); + await passMinimum(); + + await expect(result).resolves.toBe(true); + expect(store().phase).toBe("ready"); + }); + + // A secret-key import finishes almost at once; the title would flash. + it("keeps the setting-up title for the minimum time when setup is quick", async () => { + const result = runWithWalletSetupOverlay("import", () => + Promise.resolve(true), + ); + + await jest.advanceTimersByTimeAsync(SETTING_UP_MIN_MS - 1); + expect(store().phase).toBe("settingUp"); + + await jest.advanceTimersByTimeAsync(1); + await expect(result).resolves.toBe(true); + expect(store().phase).toBe("ready"); + }); + + it("adds no time when setup outlasts the minimum", async () => { + let finish: (value: boolean) => void = () => {}; + const result = runWithWalletSetupOverlay( + "import", + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + + await jest.advanceTimersByTimeAsync(SETTING_UP_MIN_MS * 4); finish(true); await expect(result).resolves.toBe(true); @@ -130,20 +171,26 @@ describe("walletSetupOverlay", () => { }); it("fails the screen when setup resolves false", async () => { - await expect( - runWithWalletSetupOverlay("import", () => Promise.resolve(false)), - ).resolves.toBe(false); + const result = runWithWalletSetupOverlay("import", () => + Promise.resolve(false), + ); + await passMinimum(); + await expect(result).resolves.toBe(false); expect(store().phase).toBe("leaving"); }); it("fails the screen and rethrows when setup throws", async () => { const error = new Error("setup broke"); - await expect( - runWithWalletSetupOverlay("create", () => Promise.reject(error)), - ).rejects.toBe(error); + const result = runWithWalletSetupOverlay("create", () => + Promise.reject(error), + ); + // Caught here so the rejection isn't reported before it's awaited. + const settled = result.catch((thrown: unknown) => thrown); + await passMinimum(); + await expect(settled).resolves.toBe(error); expect(store().phase).toBe("leaving"); }); }); diff --git a/src/ducks/walletSetupOverlay.ts b/src/ducks/walletSetupOverlay.ts index b13518a78..ba40d0818 100644 --- a/src/ducks/walletSetupOverlay.ts +++ b/src/ducks/walletSetupOverlay.ts @@ -91,9 +91,18 @@ export const useWalletSetupOverlayStore = create()( ); /** - * Runs the last onboarding step's setup (`signUp` or `importWallet`) behind - * the wallet setup screen: shows it, then turns it to "ready" if setup - * resolves true, or fades it away otherwise. + * Shortest time "Creating your wallet..." or "Importing your wallet..." + * stays up. A secret-key import has no account discovery and finishes almost + * at once, which would otherwise show the title as a flash. + */ +export const SETTING_UP_MIN_MS = 500; + +/** + * Runs the last onboarding step's setup (`signUp`, `importWallet` or + * `importWalletFromSecretKey`) behind the wallet setup screen: shows it, + * then turns it to "ready" if setup resolves true, or fades it away + * otherwise. Either way the setting-up title stays up for at least + * SETTING_UP_MIN_MS. * * It owns the outcome in a `finally`, so whatever the setup does, including * throwing, the screen never stays up. It also works after the screen that @@ -106,12 +115,17 @@ export const runWithWalletSetupOverlay = async ( setup: () => Promise, ): Promise => { useWalletSetupOverlayStore.getState().begin(flow); + // Started alongside setup, so it only adds time when setup finishes early. + const minimumWait = new Promise((resolve) => { + setTimeout(resolve, SETTING_UP_MIN_MS); + }); let succeeded = false; try { succeeded = await setup(); return succeeded; } finally { + await minimumWait; const { succeed, fail } = useWalletSetupOverlayStore.getState(); if (succeeded) { succeed(); From 7c23d7b9acbd44393cef847b6c3e0f9a7f4312c6 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 13 Nov 2026 11:02:03 -0700 Subject: [PATCH 09/14] fix(auth): leave no keystore entry behind when indexing fails storeKeys removes a key it just wrote if adding it to the index fails, then rethrows the index error. clearAllData also removes every stellarwallet_* keychain entry after the index-based removal, so an entry the index doesn't list can't survive a wipe. --- __tests__/ducks/auth.test.ts | 5 ++ .../ReactNativeKeychainFacade.test.ts | 45 +++++++++++++ .../ReactNativeKeychainKeyStore.test.ts | 63 +++++++++++++++++++ src/ducks/auth.ts | 34 ++++++---- .../keyManager/ReactNativeKeychainFacade.ts | 27 ++++++++ .../keyManager/ReactNativeKeychainKeyStore.ts | 10 ++- 6 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 __tests__/helpers/keyManager/ReactNativeKeychainFacade.test.ts create mode 100644 __tests__/helpers/keyManager/ReactNativeKeychainKeyStore.test.ts diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 71871e3bc..41820e667 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -46,6 +46,7 @@ import { import { createKeyManager } from "helpers/keyManager/keyManager"; import { clearScreenshotDek } from "helpers/screenshotCrypto"; import { AppState } from "react-native"; +import * as Keychain from "react-native-keychain"; import { getSupportedBiometryType, BIOMETRY_TYPE } from "react-native-keychain"; import { analytics } from "services/analytics"; import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair"; @@ -3328,6 +3329,10 @@ describe("auth duck", () => { ); // Wiped before storing, and again to drop the partial writes. expect(clearNonSensitiveData).toHaveBeenCalledTimes(2); + // Each wipe also sweeps keystore entries the index doesn't list. + expect(Keychain.getAllGenericPasswordServices).toHaveBeenCalledTimes( + 2, + ); expect(dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST]).toBeUndefined(); expect( dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES], diff --git a/__tests__/helpers/keyManager/ReactNativeKeychainFacade.test.ts b/__tests__/helpers/keyManager/ReactNativeKeychainFacade.test.ts new file mode 100644 index 000000000..4d3753920 --- /dev/null +++ b/__tests__/helpers/keyManager/ReactNativeKeychainFacade.test.ts @@ -0,0 +1,45 @@ +import { logger } from "config/logger"; +import { removeAllKeystoreEntries } from "helpers/keyManager/ReactNativeKeychainFacade"; +import * as Keychain from "react-native-keychain"; + +jest.mock("config/logger", () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +describe("removeAllKeystoreEntries", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("removes every keystore entry and the index, indexed or not, and nothing else", async () => { + (Keychain.getAllGenericPasswordServices as jest.Mock).mockResolvedValue([ + "stellarwallet_index", + "stellarwallet_indexed-key", + "stellarwallet_orphaned-key", + "freighter_secure_storage_hashKey", + "freighter_biometric_storage_password", + ]); + + await removeAllKeystoreEntries(); + + const removed = (Keychain.resetGenericPassword as jest.Mock).mock.calls.map( + ([options]: [{ service: string }]) => options.service, + ); + expect(removed.sort()).toEqual([ + "stellarwallet_index", + "stellarwallet_indexed-key", + "stellarwallet_orphaned-key", + ]); + }); + + // Part of the wipe, which must go on. + it("logs and resolves when the keychain can't be listed", async () => { + (Keychain.getAllGenericPasswordServices as jest.Mock).mockRejectedValue( + new Error("locked"), + ); + + await expect(removeAllKeystoreEntries()).resolves.toBeUndefined(); + expect(Keychain.resetGenericPassword).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/helpers/keyManager/ReactNativeKeychainKeyStore.test.ts b/__tests__/helpers/keyManager/ReactNativeKeychainKeyStore.test.ts new file mode 100644 index 000000000..13891d918 --- /dev/null +++ b/__tests__/helpers/keyManager/ReactNativeKeychainKeyStore.test.ts @@ -0,0 +1,63 @@ +import { EncryptedKey } from "@stellar/typescript-wallet-sdk-km"; +import { ReactNativeKeychainKeyStore } from "helpers/keyManager/ReactNativeKeychainKeyStore"; + +const mockFacade = { + configure: jest.fn(), + hasKey: jest.fn(), + setKey: jest.fn(), + addToKeyIndex: jest.fn(), + removeKey: jest.fn(), +}; +jest.mock("helpers/keyManager/ReactNativeKeychainFacade", () => ({ + ReactNativeKeychainFacade: jest.fn(() => mockFacade), +})); + +const encryptedKey = (id: string) => ({ id }) as unknown as EncryptedKey; + +describe("ReactNativeKeychainKeyStore.storeKeys", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFacade.hasKey.mockResolvedValue(false); + mockFacade.setKey.mockResolvedValue(undefined); + mockFacade.addToKeyIndex.mockResolvedValue(undefined); + mockFacade.removeKey.mockResolvedValue(undefined); + }); + + it("stores and indexes each key", async () => { + const store = new ReactNativeKeychainKeyStore(); + + await expect( + store.storeKeys([encryptedKey("a"), encryptedKey("b")]), + ).resolves.toEqual([{ id: "a" }, { id: "b" }]); + + expect(mockFacade.addToKeyIndex.mock.calls).toEqual([["a"], ["b"]]); + expect(mockFacade.removeKey).not.toHaveBeenCalled(); + }); + + // Wipes find keys through the index; an unindexed entry would outlive them. + it("removes the entry it just wrote when indexing it fails, and rethrows", async () => { + const indexError = new Error("index write failed"); + mockFacade.addToKeyIndex + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(indexError); + const store = new ReactNativeKeychainKeyStore(); + + await expect( + store.storeKeys([encryptedKey("a"), encryptedKey("b")]), + ).rejects.toBe(indexError); + + // Only the unindexed one: "a" is indexed, so a wipe still finds it. + expect(mockFacade.removeKey.mock.calls).toEqual([["b"]]); + }); + + it("still rethrows the index error when removing the entry fails", async () => { + const indexError = new Error("index write failed"); + mockFacade.addToKeyIndex.mockRejectedValue(indexError); + // The facade logs and swallows its own removal failures. + mockFacade.removeKey.mockResolvedValue(undefined); + const store = new ReactNativeKeychainKeyStore(); + + await expect(store.storeKeys([encryptedKey("a")])).rejects.toBe(indexError); + expect(mockFacade.removeKey).toHaveBeenCalledWith("a"); + }); +}); diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 7806afde9..81120145e 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -57,6 +57,7 @@ import { encryptDataWithDerivedKey, } from "helpers/encryptPassword"; import { findAccountById, findAccountByPublicKey } from "helpers/findAccount"; +import { removeAllKeystoreEntries } from "helpers/keyManager/ReactNativeKeychainFacade"; import { createKeyManager } from "helpers/keyManager/keyManager"; import { markRebrandAnnouncementSeen } from "helpers/rebrandAnnouncement"; import { @@ -1411,6 +1412,11 @@ export const clearPendingRecoveryPhrase = (pendingPhraseId?: string): void => { * Clears all data from the key manager, temporary store (sensitive), * and non-sensitive data * + * Keys are removed through the key manager's index, then every keystore + * entry left in the keychain is swept, indexed or not. An entry whose index + * write failed, or an index that lost ids, would otherwise survive the wipe, + * and iOS keeps keychain items across reinstalls. + * * @returns {Promise} */ const clearAllData = async (): Promise => { @@ -1418,17 +1424,23 @@ const clearAllData = async (): Promise => { clearCachedWalletSources(); clearPendingRecoveryPhrase(); - const allKeys = await keyManager.loadAllKeyIds(); - - await Promise.all([ - ...allKeys.map((key) => keyManager.removeKey(key)), - clearTemporaryData(), - clearNonSensitiveData(), - // clearNonSensitiveData removes the map too; this also moves the epoch, - // so a link pass from the replaced wallet stores nothing. - clearLinkStatus(), - dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST), - ]); + try { + const allKeys = await keyManager.loadAllKeyIds(); + + await Promise.all([ + ...allKeys.map((key) => keyManager.removeKey(key)), + clearTemporaryData(), + clearNonSensitiveData(), + // clearNonSensitiveData removes the map too; this also moves the epoch, + // so a link pass from the replaced wallet stores nothing. + clearLinkStatus(), + dataStorage.remove(STORAGE_KEYS.COLLECTIBLES_LIST), + ]); + } finally { + // After the index-based removals, so it doesn't race their index + // writes, and even when one of them failed. + await removeAllKeystoreEntries(); + } }; /** diff --git a/src/helpers/keyManager/ReactNativeKeychainFacade.ts b/src/helpers/keyManager/ReactNativeKeychainFacade.ts index 92855dc36..6b050f870 100644 --- a/src/helpers/keyManager/ReactNativeKeychainFacade.ts +++ b/src/helpers/keyManager/ReactNativeKeychainFacade.ts @@ -51,6 +51,33 @@ const keychainErrorDetails = (error: unknown): Record => { return details; }; +/** + * Removes every keystore entry in the default service, indexed or not, and + * the index itself. A wipe that only walks the index misses an entry whose + * index write failed or an index that lost ids, so the wipe runs this after + * it. Like `secureStorage.clear`, it logs a failure instead of throwing, so + * the wipe goes on. + */ +export const removeAllKeystoreEntries = async (): Promise => { + try { + const prefix = `${DEFAULT_SERVICE}_`; + const services = await Keychain.getAllGenericPasswordServices(); + await Promise.all( + services + .filter((service) => service.startsWith(prefix)) + .map((service) => Keychain.resetGenericPassword({ service })), + ); + } catch (error) { + // Part of the wipe: a keystore left on the device is security relevant. + logger.error( + "ReactNativeKeychainFacade.removeAllKeystoreEntries", + "Error removing keystore entries", + error, + keychainErrorDetails(error), + ); + } +}; + /** * Facade for secure storage using react-native-keychain * This provides a simple interface for storing encrypted keys diff --git a/src/helpers/keyManager/ReactNativeKeychainKeyStore.ts b/src/helpers/keyManager/ReactNativeKeychainKeyStore.ts index 54b85cadf..b53b6e293 100644 --- a/src/helpers/keyManager/ReactNativeKeychainKeyStore.ts +++ b/src/helpers/keyManager/ReactNativeKeychainKeyStore.ts @@ -99,7 +99,15 @@ export class ReactNativeKeychainKeyStore implements KeyStore { for (const encryptedKey of keys) { await this.keyStore.setKey(encryptedKey.id, encryptedKey); - await this.keyStore.addToKeyIndex(encryptedKey.id); + try { + await this.keyStore.addToKeyIndex(encryptedKey.id); + } catch (error) { + // Wipes find keys through the index, so an unindexed entry would + // outlive every later wipe. removeKey logs and swallows its own + // failure, so the index error is the one the caller sees. + await this.keyStore.removeKey(encryptedKey.id); + throw error; + } keysMetadata.push(getKeyMetadata(encryptedKey)); } From 6d1b0702bdbc2f574b6a8f64efbc35768fbfae27 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 13 Nov 2026 11:16:14 -0700 Subject: [PATCH 10/14] fix(auth): send fixed failure codes from the secret-key imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit importWalletFromSecretKey and importSecretKey send a fixed reason_code to analytics instead of the scrubbed error text, which can carry the key in a form scrubStrKeys misses (key_S…). Adds the missing trackAccountScreenImportAccountFail to the analytics mock in jest.setup.js. --- __tests__/ducks/auth.test.ts | 90 +++++++++++++++++++++++++- jest.setup.js | 1 + src/ducks/auth.ts | 23 ++++--- src/services/analytics/transactions.ts | 7 +- 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 41820e667..9f3133e5e 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -3137,6 +3137,90 @@ describe("auth duck", () => { expect(linkWalletSources).toHaveBeenCalledTimes(1); }); + describe("importSecretKey failure analytics", () => { + const importKey = async (secretKey = importedSecretKey) => { + const { result } = renderHook(() => useAuthenticationStore()); + act(() => { + useAuthenticationStore.setState({ + importSecretKey: originalStoreMethods.importSecretKey, + }); + }); + await act(async () => { + await result.current + .importSecretKey({ secretKey, password: mockPassword }) + .catch(() => undefined); + }); + }; + + beforeEach(() => { + dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId; + dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([ + { + id: mockAccountId, + name: mockAccountName, + publicKey: mockPublicKey, + }, + ]); + }); + + it("reports an invalid key", async () => { + mockAuthenticatedSession(mockTemporaryStore); + + await importKey(`${importedSecretKey.slice(0, -1)}A`); + + expect( + analytics.trackAccountScreenImportAccountFail, + ).toHaveBeenCalledWith("invalid_secret_key"); + }); + + it("reports a key the wallet already holds", async () => { + mockAuthenticatedSession(mockTemporaryStore); + dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([ + { + id: mockAccountId, + name: mockAccountName, + publicKey: importedPublicKey, + }, + ]); + + await importKey(); + + expect( + analytics.trackAccountScreenImportAccountFail, + ).toHaveBeenCalledWith("account_already_exists"); + }); + + it("reports a missing session", async () => { + await importKey(); + + expect( + analytics.trackAccountScreenImportAccountFail, + ).toHaveBeenCalledWith("temporary_store_not_found"); + }); + + // `key_S…` has no word boundary before the key, so scrubStrKeys + // would let it through. + it("reports a failed store with a fixed code, never the error text", async () => { + mockAuthenticatedSession(mockTemporaryStore); + sharedKeyManager.storeKey.mockRejectedValue( + new Error(`could not store key_${importedSecretKey}`), + ); + + await importKey(); + + expect( + analytics.trackAccountScreenImportAccountFail, + ).toHaveBeenCalledWith("store_failed"); + expect( + JSON.stringify([ + (analytics.track as jest.Mock).mock.calls, + (analytics.trackAccountScreenImportAccountFail as jest.Mock).mock + .calls, + ]), + ).not.toContain(importedSecretKey); + }); + }); + it("importSecretKey leaves the active account alone when the account list write fails", async () => { mockAuthenticatedSession(mockTemporaryStore); dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId; @@ -3339,15 +3423,17 @@ describe("auth duck", () => { ).toBeUndefined(); expect(analytics.track).toHaveBeenCalledWith( AnalyticsEvent.RECOVER_ACCOUNT_FAIL, - expect.objectContaining({ recovery_method: "secret_key" }), + { recovery_method: "secret_key", reason_code: "store_failed" }, ); expect(linkWalletSources).not.toHaveBeenCalled(); }, ); + // `key_S…` has no word boundary before the key, so scrubStrKeys + // would let it through. it("never logs or tracks the key", async () => { sharedKeyManager.storeKey.mockRejectedValue( - new Error(`could not store ${importedSecretKey}`), + new Error(`could not store key_${importedSecretKey}`), ); await runImport(); diff --git a/jest.setup.js b/jest.setup.js index 8feee933e..3d2203cdc 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -286,6 +286,7 @@ jest.mock("services/analytics", () => ({ trackInternalSignedTransactionError: jest.fn(), trackSimulationError: jest.fn(), trackCopyPublicKey: jest.fn(), + trackAccountScreenImportAccountFail: jest.fn(), trackSendPaymentSuccess: jest.fn(), trackSendPaymentPathPaymentSuccess: jest.fn(), trackSwapSuccess: jest.fn(), diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 81120145e..9594a3276 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2595,13 +2595,12 @@ const importWalletFromSecretKey = async ({ analytics.track(AnalyticsEvent.RECOVER_ACCOUNT_SUCCESS, { recovery_method: "secret_key", }); - } catch (error) { - // Scrubbed: the message can carry uncontrolled native text. - const importFailureMessage = - error instanceof Error ? error.message : String(error); + } catch { + // A fixed code, never the error text: native keychain or encrypter text + // could carry the key in a form scrubStrKeys misses (e.g. `key_S…`). analytics.track(AnalyticsEvent.RECOVER_ACCOUNT_FAIL, { recovery_method: "secret_key", - reason_code: scrubStrKeys(importFailureMessage) ?? importFailureMessage, + reason_code: "store_failed", }); // The previous wallet is already gone, so drop the partial writes too. clearAccountData(); @@ -3266,11 +3265,15 @@ const importSecretKeyLocal = async ( authStatus: AuthStatus, ): Promise => { const { secretKey, password } = params; + // What failure analytics gets: a fixed code set as the import goes, never + // the error text, which could carry the key in a form scrubStrKeys misses. + let reasonCode = "invalid_secret_key"; try { const keypair = Keypair.fromSecret(secretKey); const publicKey = keypair.publicKey(); const privateKey = keypair.secret(); + reasonCode = "store_failed"; const existingAccounts = await getAllAccounts(); const accountExists = hasAccountInAccountList(existingAccounts, { @@ -3279,6 +3282,7 @@ const importSecretKeyLocal = async ( }); if (accountExists) { + reasonCode = "account_already_exists"; throw new Error(t("authStore.error.accountAlreadyExists")); } @@ -3287,6 +3291,7 @@ const importSecretKeyLocal = async ( // from its own key, and nothing of any phrase is written into it. const temporaryStore = await getTemporaryStore(authStatus); if (!temporaryStore) { + reasonCode = "temporary_store_not_found"; throw new Error(t("authStore.error.temporaryStoreNotFound")); } @@ -3314,13 +3319,7 @@ const importSecretKeyLocal = async ( import_method: "secret_key", }); } catch (error) { - // Scrub Stellar StrKeys before sending to analytics — third-party sink - // (Amplitude) not covered by Sentry's beforeSend. - const importFailureMessage = - error instanceof Error ? error.message : String(error); - analytics.trackAccountScreenImportAccountFail( - scrubStrKeys(importFailureMessage) ?? importFailureMessage, - ); + analytics.trackAccountScreenImportAccountFail(reasonCode); throw error; } }; diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index 6946de3c7..594bbf486 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -427,9 +427,10 @@ export const trackAccountScreenImportAccountFail = (error: string): void => { // secret-key import path (mnemonic restore emits account_recovery.* instead). track(AnalyticsEvent.ACCOUNT_SCREEN_IMPORT_ACCOUNT_FAIL, { import_method: "secret_key", - // Scrub inside the helper (not just at the caller): this is the secret-key - // import path, so a failure message is the likeliest place to embed an S… - // seed. Matches the other track*Error helpers. + // The auth store passes a fixed reason code, never error text: a raw + // secret key is in hand on this path, and scrubStrKeys misses a key + // glued to `_` or a letter. The scrub stays as a backstop, matching the + // other track*Error helpers. reason_code: scrubStrKeys(error) ?? error, }); }; From 5400c13c7a28d90ae2b68495cdfa2cd5bdf65e97 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 13 Nov 2026 11:51:36 -0700 Subject: [PATCH 11/14] fix(auth): settle setup writes and drop the biometric password on rollback storeAccount waits for every write it started before it throws, using a new settleAll helper, so nothing lands after a rollback. Failed sign-up, phrase import and key import share rollBackWalletSetup, which also removes BIOMETRIC_PASSWORD. The Biometrics screen saves the password again before each Enable attempt so a retry still works. --- .../BiometricsEnableScreen.test.tsx | 47 ++++ __tests__/ducks/auth.test.ts | 248 ++++++++++++++++++ __tests__/helpers/settleAll.test.ts | 53 ++++ .../BiometricsEnableScreen.tsx | 7 + src/ducks/auth.ts | 35 ++- src/helpers/settleAll.ts | 23 ++ 6 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 __tests__/helpers/settleAll.test.ts create mode 100644 src/helpers/settleAll.ts diff --git a/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx b/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx index 7291c6dc7..355a9dfc9 100644 --- a/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx +++ b/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx @@ -23,12 +23,14 @@ const mockSignUp = jest.fn(); const mockImportWallet = jest.fn(); const mockImportWalletFromSecretKey = jest.fn(); const mockEnableBiometrics = jest.fn(); +const mockStoreBiometricPassword = jest.fn(); jest.mock("ducks/auth", () => ({ useAuthenticationStore: () => ({ isLoading: false, signUp: mockSignUp, importWallet: mockImportWallet, importWalletFromSecretKey: mockImportWalletFromSecretKey, + storeBiometricPassword: mockStoreBiometricPassword, enableBiometrics: mockEnableBiometrics, }), })); @@ -294,6 +296,49 @@ describe("BiometricsOnboardingScreen", () => { expect(mockImportWallet).not.toHaveBeenCalled(); }); + // A failed setup removes the biometric password, which the check reads, + // so each attempt saves it again from login data first. + it.each([BiometricsSource.ONBOARDING, BiometricsSource.IMPORT_WALLET])( + "saves the password before the biometric check (%s)", + async (source) => { + passBiometricCheck("biometric-password"); + mockSignUp.mockResolvedValue(true); + mockImportWallet.mockResolvedValue(true); + const { getByText } = renderScreen(source); + + fireEvent.press(getByText("Enable Face ID")); + + await waitFor(() => + expect(mockSetIsBiometricsEnabled).toHaveBeenCalledWith(true), + ); + expect(mockStoreBiometricPassword).toHaveBeenCalledWith("password1"); + expect( + mockStoreBiometricPassword.mock.invocationCallOrder[0], + ).toBeLessThan(mockEnableBiometrics.mock.invocationCallOrder[0]); + }, + ); + + it("saves the password again on a retry after a failed setup", async () => { + passBiometricCheck("biometric-password"); + mockSignUp.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const { getByText } = renderScreen(); + + fireEvent.press(getByText("Enable Face ID")); + await waitFor(() => + expect(mockNotifySetupFailed).toHaveBeenCalledTimes(1), + ); + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + fireEvent.press(getByText("Enable Face ID")); + await waitFor(() => + expect(mockSetIsBiometricsEnabled).toHaveBeenCalledWith(true), + ); + expect(mockStoreBiometricPassword).toHaveBeenCalledTimes(2); + expect(mockSignUp).toHaveBeenCalledTimes(2); + }); + it("leaves biometrics off and keeps the phrase when setup fails", async () => { passBiometricCheck("biometric-password"); mockSignUp.mockResolvedValue(false); @@ -454,6 +499,8 @@ describe("BiometricsOnboardingScreen", () => { expect(mockEnableBiometrics).toHaveBeenCalledTimes(1); expect(overlay().phase).toBe("hidden"); + // The wallet and its biometric password already exist after onboarding. + expect(mockStoreBiometricPassword).not.toHaveBeenCalled(); }); }); }); diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 9f3133e5e..1e8c538e3 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -8,6 +8,7 @@ import { NETWORKS, STORAGE_KEYS, SENSITIVE_STORAGE_KEYS, + BIOMETRIC_STORAGE_KEYS, LoginType, } from "config/constants"; import { logger } from "config/logger"; @@ -125,6 +126,7 @@ jest.mock("services/storage/storageFactory", () => ({ getItem: jest.fn(), setItem: jest.fn(), checkIfExists: jest.fn(), + remove: jest.fn(), }, })); @@ -3072,6 +3074,69 @@ describe("auth duck", () => { expect(linkWalletSources).not.toHaveBeenCalled(); }); + // The order of the biometric password's save and its removal. + const biometricPasswordWriteOrder = () => { + const savedAt = ( + biometricDataStorage.setItem as jest.Mock + ).mock.calls.findIndex( + ([key]) => key === BIOMETRIC_STORAGE_KEYS.BIOMETRIC_PASSWORD, + ); + const removedAt = ( + biometricDataStorage.remove as jest.Mock + ).mock.calls.findIndex( + ([key]) => key === BIOMETRIC_STORAGE_KEYS.BIOMETRIC_PASSWORD, + ); + return { + saved: + savedAt === -1 + ? undefined + : (biometricDataStorage.setItem as jest.Mock).mock + .invocationCallOrder[savedAt], + removed: + removedAt === -1 + ? undefined + : (biometricDataStorage.remove as jest.Mock).mock + .invocationCallOrder[removedAt], + }; + }; + + // Fails the source registration, which runs after storeAccount saved + // the biometric password. + const failSourceRegistration = () => + (dataStorage.setItem as jest.Mock).mockImplementation( + (key: STORAGE_KEYS, value: string) => { + if (key === STORAGE_KEYS.SECRET_SOURCES) { + return Promise.reject(new Error("write failed")); + } + dataStorageMemory[key] = value; + return Promise.resolve(); + }, + ); + + it.each(["signUp", "importWallet"] as const)( + "a failed %s removes the biometric password it saved", + async (action) => { + failSourceRegistration(); + const { result } = renderHook(() => useAuthenticationStore()); + act(() => { + useAuthenticationStore.setState({ + [action]: originalStoreMethods[action], + }); + }); + + await act(async () => { + await result.current[action]({ + mnemonicPhrase: mockMnemonicPhrase, + password: mockPassword, + }); + }); + + const { saved, removed } = biometricPasswordWriteOrder(); + expect(saved).toBeDefined(); + expect(removed).toBeGreaterThan(saved as number); + }, + ); + it("importSecretKey records the key as its own source and stamps no phrase into its keystore", async () => { mockAuthenticatedSession(mockTemporaryStore); dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId; @@ -3359,6 +3424,11 @@ describe("auth duck", () => { AnalyticsEvent.RECOVER_ACCOUNT_SUCCESS, { recovery_method: "secret_key" }, ); + expect(biometricDataStorage.setItem).toHaveBeenCalledWith( + BIOMETRIC_STORAGE_KEYS.BIOMETRIC_PASSWORD, + mockPassword, + ); + expect(biometricDataStorage.remove).not.toHaveBeenCalled(); }); it("leaves the wallet untouched when the key is invalid", async () => { @@ -3426,9 +3496,187 @@ describe("auth duck", () => { { recovery_method: "secret_key", reason_code: "store_failed" }, ); expect(linkWalletSources).not.toHaveBeenCalled(); + expect(biometricDataStorage.remove).toHaveBeenCalledWith( + BIOMETRIC_STORAGE_KEYS.BIOMETRIC_PASSWORD, + ); }, ); + // One of storeAccount's writes fails while the biometric save is still + // running. The removal must wait for it, or the save would land after + // the rollback and leave the password behind. + it("removes the biometric password only after its save has finished", async () => { + let finishSave: () => void = () => {}; + (biometricDataStorage.setItem as jest.Mock).mockImplementationOnce( + () => + new Promise((resolve) => { + finishSave = resolve; + }), + ); + (dataStorage.setItem as jest.Mock).mockImplementation( + (key: STORAGE_KEYS, value: string) => { + if (key === STORAGE_KEYS.ACTIVE_ACCOUNT_ID) { + return Promise.reject(new Error("write failed")); + } + dataStorageMemory[key] = value; + return Promise.resolve(); + }, + ); + const { result } = renderHook(() => useAuthenticationStore()); + act(() => { + useAuthenticationStore.setState({ + importWalletFromSecretKey: + originalStoreMethods.importWalletFromSecretKey, + }); + }); + + let succeeded: boolean | undefined; + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current + .importWalletFromSecretKey({ + secretKey: importedSecretKey, + password: mockPassword, + }) + .then((value) => { + succeeded = value; + }); + }); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + expect(biometricDataStorage.setItem).toHaveBeenCalledWith( + BIOMETRIC_STORAGE_KEYS.BIOMETRIC_PASSWORD, + mockPassword, + ); + expect(biometricDataStorage.remove).not.toHaveBeenCalled(); + + await act(async () => { + finishSave(); + await pending; + }); + + expect(succeeded).toBe(false); + expect(biometricDataStorage.remove).toHaveBeenCalledWith( + BIOMETRIC_STORAGE_KEYS.BIOMETRIC_PASSWORD, + ); + }); + + /** Starts the import without waiting for it. */ + const startImport = () => { + const { result } = renderHook(() => useAuthenticationStore()); + act(() => { + useAuthenticationStore.setState({ + importWalletFromSecretKey: + originalStoreMethods.importWalletFromSecretKey, + }); + }); + let succeeded: boolean | undefined; + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current + .importWalletFromSecretKey({ + secretKey: importedSecretKey, + password: mockPassword, + }) + .then((value) => { + succeeded = value; + }); + }); + return { pending, succeeded: () => succeeded }; + }; + + /** Lets every promise that can run, run. */ + const flush = () => + act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + + // The active-account write fails while the temporary store is still + // being written. Rolling back then would let that write bring the + // session back after the wipe. + it("waits for the temporary-store write before rolling back", async () => { + let finishTemporaryStore: () => void = () => {}; + const setSecureItem = secureDataStorage.setItem as jest.Mock; + const originalSetSecureItem = setSecureItem.getMockImplementation(); + setSecureItem.mockImplementation((key: SENSITIVE_STORAGE_KEYS) => + key === SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE + ? new Promise((resolve) => { + finishTemporaryStore = resolve; + }) + : Promise.resolve(), + ); + (dataStorage.setItem as jest.Mock).mockImplementation( + (key: STORAGE_KEYS, value: string) => { + if (key === STORAGE_KEYS.ACTIVE_ACCOUNT_ID) { + return Promise.reject(new Error("write failed")); + } + dataStorageMemory[key] = value; + return Promise.resolve(); + }, + ); + + try { + const run = startImport(); + await flush(); + + expect(setSecureItem).toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.TEMPORARY_STORE, + expect.anything(), + ); + // Only the wipe before storing has run. + expect(clearTemporaryData).toHaveBeenCalledTimes(1); + + await act(async () => { + finishTemporaryStore(); + await run.pending; + }); + + expect(run.succeeded()).toBe(false); + expect(clearTemporaryData).toHaveBeenCalledTimes(2); + } finally { + setSecureItem.mockImplementation(originalSetSecureItem); + } + }); + + // The account-list read fails while the key is still being stored. + // Rolling back then would let the keystore write land after the wipe. + it("waits for the keystore write before rolling back", async () => { + let finishStoreKey: () => void = () => {}; + sharedKeyManager.storeKey.mockImplementationOnce( + () => + new Promise((resolve) => { + finishStoreKey = () => resolve({ id: "imported-account-id" }); + }), + ); + (dataStorage.getItem as jest.Mock).mockImplementation( + (key: STORAGE_KEYS) => + key === STORAGE_KEYS.ACCOUNT_LIST + ? Promise.reject(new Error("read failed")) + : Promise.resolve(dataStorageMemory[key] ?? null), + ); + + const run = startImport(); + await flush(); + + expect(sharedKeyManager.storeKey).toHaveBeenCalledTimes(1); + // Only the wipe before storing has listed the keys. + expect(sharedKeyManager.loadAllKeyIds).toHaveBeenCalledTimes(1); + + await act(async () => { + finishStoreKey(); + await run.pending; + }); + + expect(run.succeeded()).toBe(false); + expect(sharedKeyManager.loadAllKeyIds).toHaveBeenCalledTimes(2); + }); + // `key_S…` has no word boundary before the key, so scrubStrKeys // would let it through. it("never logs or tracks the key", async () => { diff --git a/__tests__/helpers/settleAll.test.ts b/__tests__/helpers/settleAll.test.ts new file mode 100644 index 000000000..003082f7e --- /dev/null +++ b/__tests__/helpers/settleAll.test.ts @@ -0,0 +1,53 @@ +import { settleAll } from "helpers/settleAll"; + +/** A promise the test settles. */ +const deferred = () => { + let resolve: (value: T) => void = () => {}; + let reject: (error: Error) => void = () => {}; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +}; + +describe("settleAll", () => { + it("returns the values in order", async () => { + await expect( + settleAll([Promise.resolve(1), Promise.resolve("two")]), + ).resolves.toEqual([1, "two"]); + }); + + // The point of it: a write still running when another fails must finish + // before the caller can roll back. + it("waits for a slower promise before throwing an earlier rejection", async () => { + const slow = deferred(); + const failure = new Error("first"); + let settled = false; + + const result = settleAll([Promise.reject(failure), slow.promise]).catch( + (error: unknown) => { + settled = true; + return error; + }, + ); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + slow.resolve("done"); + + await expect(result).resolves.toBe(failure); + }); + + it("throws the first rejection in input order", async () => { + const later = deferred(); + const first = new Error("first"); + const second = new Error("second"); + + const result = settleAll([later.promise, Promise.reject(second)]); + later.reject(first); + + await expect(result).rejects.toBe(first); + }); +}); diff --git a/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx b/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx index 18b7dc775..9d3dec419 100644 --- a/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx +++ b/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx @@ -184,6 +184,7 @@ export const BiometricsOnboardingScreen: React.FC< signUp, importWallet, importWalletFromSecretKey, + storeBiometricPassword, enableBiometrics: enableBiometricsAction, } = useAuthenticationStore(); const { setIsBiometricsEnabled, biometryType } = useBiometrics(); @@ -232,6 +233,11 @@ export const BiometricsOnboardingScreen: React.FC< } try { + // The biometric check reads the password from biometric storage. A + // failed setup removes it there, so each attempt saves it again from + // login data, which a failure keeps; otherwise a retry would find none. + await storeBiometricPassword(password); + // The callback runs once the biometric check has passed, so the wallet // setup screen comes up then, not when Enable is tapped. await enableBiometricsAction((biometricPassword) => @@ -278,6 +284,7 @@ export const BiometricsOnboardingScreen: React.FC< signUp, importWallet, importWalletFromSecretKey, + storeBiometricPassword, enableBiometricsAction, navigation, mnemonicPhrase, diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 9594a3276..5f25ef86e 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -65,6 +65,7 @@ import { normalizeAndTrimRecoveryPhrase, } from "helpers/recoveryPhrase"; import { clearScreenshotDek } from "helpers/screenshotCrypto"; +import { settleAll } from "helpers/settleAll"; import { scrubStrKeys } from "helpers/stellarStrKey"; import { clearWalletKitStorage } from "helpers/walletKitUtil"; import { t } from "i18next"; @@ -1254,7 +1255,9 @@ const storeAccount = async ({ encrypterName: ScryptEncrypter.name, }; - const [keyStore, accountListRaw] = await Promise.all([ + // Settled, not raced: a failed read must not leave the keystore write + // running past the caller's rollback, which would keep the key on device. + const [keyStore, accountListRaw] = await settleAll([ keyManager.storeKey(keyMetadata), dataStorage.getItem(STORAGE_KEYS.ACCOUNT_LIST), ]); @@ -1295,7 +1298,10 @@ const storeAccount = async ({ importedFromSecretKey, }); - await Promise.all([ + // Settled, not raced: when one write fails the others still finish before + // this throws, so a rollback after it can't be undone by a late write + // (e.g. the biometric password landing after the rollback removed it). + await settleAll([ shouldSetActiveAccount ? dataStorage.setItem(STORAGE_KEYS.ACTIVE_ACCOUNT_ID, keyStore.id) : Promise.resolve(), @@ -2436,6 +2442,22 @@ const signIn = async ({ return { phraseSigners }; }; +/** + * Undoes a wallet setup (sign up, phrase import or key import) that failed + * after its wipe: the partial wallet, and the biometric password storeAccount + * saved. clearAllData leaves biometric storage alone, so without this the + * user's new password would outlive the wallet. storeAccount settles all its + * writes before it throws, so none of them lands after this. + * + * Only BIOMETRIC_PASSWORD goes: a failed setup never turned biometrics on, + * and the biometrics step saves the password again before a retry. + */ +const rollBackWalletSetup = async (): Promise => { + clearAccountData(); + await clearAllData(); + await biometricDataStorage.remove(BIOMETRIC_STORAGE_KEYS.BIOMETRIC_PASSWORD); +}; + /** * Signs up a new user with the provided credentials * @@ -2472,8 +2494,7 @@ const signUp = async ({ }); } catch (error) { // Clean up any partial data on error - clearAccountData(); - await clearAllData(); + await rollBackWalletSetup(); throw error; } @@ -2539,8 +2560,7 @@ const importWallet = async ({ reason_code: scrubStrKeys(importFailureMessage) ?? importFailureMessage, }); // Clean up any partial data on error - clearAccountData(); - await clearAllData(); + await rollBackWalletSetup(); throw error; } @@ -2603,8 +2623,7 @@ const importWalletFromSecretKey = async ({ reason_code: "store_failed", }); // The previous wallet is already gone, so drop the partial writes too. - clearAccountData(); - await clearAllData(); + await rollBackWalletSetup(); throw new Error(t("authStore.error.failedToImportWallet")); } diff --git a/src/helpers/settleAll.ts b/src/helpers/settleAll.ts new file mode 100644 index 000000000..f464e77ca --- /dev/null +++ b/src/helpers/settleAll.ts @@ -0,0 +1,23 @@ +/** + * Like `Promise.all`, but it doesn't fail fast: it waits for every promise + * to settle, then throws the first rejection's reason (in input order) or + * returns the values in order. + * + * Use it for writes that a caller rolls back on failure. With + * `Promise.all`, a write still running when another fails can land after + * the rollback and undo it. + */ +export const settleAll = async ( + promises: T, +): Promise<{ -readonly [K in keyof T]: Awaited }> => { + // Widened for the loop; the typed tuple is restored on return. + const results = await Promise.allSettled(promises as readonly unknown[]); + const values = results.map((result) => { + if (result.status === "rejected") { + throw result.reason; + } + return result.value; + }); + + return values as { -readonly [K in keyof T]: Awaited }; +}; From 01c51fa7f6314733d0de86ac09e0c160e3f56a8f Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 13 Nov 2026 12:33:50 -0700 Subject: [PATCH 12/14] fix(auth): reset auto-lock only after the import input is validated signUp, importWallet and importWalletFromSecretKey reset auto-lock themselves, just before the pre-store wipe, instead of in their store actions before validation. An invalid secret key now leaves the timer, the background timestamp and the LOCKED marker as they were. The reset still runs under the account-mutation lock and before the new hash key. --- __tests__/ducks/auth.test.ts | 35 ++++++++++++++++++++++++++++++++++ src/ducks/auth.ts | 37 ++++++++++++++++++------------------ 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/__tests__/ducks/auth.test.ts b/__tests__/ducks/auth.test.ts index 1e8c538e3..3bbb4730c 100644 --- a/__tests__/ducks/auth.test.ts +++ b/__tests__/ducks/auth.test.ts @@ -3449,6 +3449,41 @@ describe("auth duck", () => { AnalyticsEvent.RECOVER_ACCOUNT_FAIL, { recovery_method: "secret_key", reason_code: "invalid_secret_key" }, ); + // Auto-lock is reset only for a valid key: the timer and the lock + // marker are as they were. + expect(usePreferencesStore.setState).not.toHaveBeenCalledWith({ + autoLockTimer: DEFAULT_AUTO_LOCK_TIMER, + }); + expect(secureDataStorage.remove).not.toHaveBeenCalledWith( + SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + ); + }); + + // The new wallet's hash key must not inherit the previous timer. + it("resets auto-lock for a valid key before the hash key is written", async () => { + const { succeeded } = await runImport(); + + expect(succeeded).toBe(true); + expect(usePreferencesStore.setState).toHaveBeenCalledWith({ + autoLockTimer: DEFAULT_AUTO_LOCK_TIMER, + }); + const removeCalls = (secureDataStorage.remove as jest.Mock).mock; + const resetAt = + removeCalls.invocationCallOrder[ + removeCalls.calls.findIndex( + ([key]) => key === SENSITIVE_STORAGE_KEYS.AUTH_STATUS, + ) + ]; + const setCalls = (secureDataStorage.setItem as jest.Mock).mock; + const hashKeyAt = + setCalls.invocationCallOrder[ + setCalls.calls.findIndex( + ([key]) => key === SENSITIVE_STORAGE_KEYS.HASH_KEY, + ) + ]; + expect(resetAt).toBeDefined(); + expect(hashKeyAt).toBeDefined(); + expect(resetAt).toBeLessThan(hashKeyAt); }); it.each([ diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts index 5f25ef86e..804a6f5b7 100644 --- a/src/ducks/auth.ts +++ b/src/ducks/auth.ts @@ -2475,6 +2475,10 @@ const signUp = async ({ const keyPair = deriveKeyPair({ mnemonicPhrase }); const phraseSourceId = await getPhraseSourceId(mnemonicPhrase); + // A new wallet: reset auto-lock to the default before storeAccount + // generates the hash key, so it doesn't inherit the previous timer. + // Only once the input is known good, so an invalid one changes nothing. + await resetAutoLockForNewWallet(); clearAccountData(); await clearAllData(); @@ -2517,6 +2521,10 @@ const importWallet = async ({ const keyPair = deriveKeyPair({ mnemonicPhrase }); const phraseSourceId = await getPhraseSourceId(mnemonicPhrase); + // A new wallet: reset auto-lock to the default before storeAccount + // generates the hash key, so it doesn't inherit the previous timer. + // Only once the input is known good, so an invalid one changes nothing. + await resetAutoLockForNewWallet(); clearAccountData(); await clearAllData(); @@ -2598,6 +2606,10 @@ const importWalletFromSecretKey = async ({ } try { + // A new wallet: reset auto-lock to the default before storeAccount + // generates the hash key, so it doesn't inherit the previous timer. + // Only once the input is known good, so an invalid one changes nothing. + await resetAutoLockForNewWallet(); clearAccountData(); await clearAllData(); @@ -3679,12 +3691,8 @@ export const useAuthenticationStore = create()((set, get) => ({ signUp: async (params): Promise => { set((state) => ({ ...state, isLoading: true, error: null })); try { - // Fresh wallet: reset auto-lock to the default before the hash key is - // generated so it doesn't inherit a previous wallet's timer. - await withAccountMutationLock(async () => { - await resetAutoLockForNewWallet(); - await signUp(params); - }); + // signUp resets auto-lock itself, once the phrase is known good. + await withAccountMutationLock(() => signUp(params)); // Mirror the extension's onboarding.password_created (createAccount.fulfilled) // so the create-password funnel has a success side on mobile (was fail-only). analytics.track(AnalyticsEvent.CREATE_PASSWORD_SUCCESS); @@ -4188,12 +4196,8 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isLoading: true, error: null }); try { - // Re-import replaces the wallet: reset auto-lock to the default before - // the hash key is generated so it doesn't inherit the previous timer. - await withAccountMutationLock(async () => { - await resetAutoLockForNewWallet(); - await importWallet(params); - }); + // importWallet resets auto-lock itself, once the phrase is known good. + await withAccountMutationLock(() => importWallet(params)); set({ ...initialState, navigationRef: get().navigationRef, @@ -4233,12 +4237,9 @@ export const useAuthenticationStore = create()((set, get) => ({ set({ isLoading: true, error: null }); try { - // Replaces the wallet: reset auto-lock to the default before the hash - // key is generated so it doesn't inherit the previous timer. - await withAccountMutationLock(async () => { - await resetAutoLockForNewWallet(); - await importWalletFromSecretKey(params); - }); + // importWalletFromSecretKey resets auto-lock itself, once the key is + // known good. + await withAccountMutationLock(() => importWalletFromSecretKey(params)); set({ ...initialState, navigationRef: get().navigationRef, From 8bd79db402cbcfeb693feb9b609a5363cfa8079c Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 13 Nov 2026 12:39:48 -0700 Subject: [PATCH 13/14] fix(settings): show the recovery phrase row when the sources read fails useHasRecoveryPhrase falls back to true when reading the secret sources fails, as its doc says, so a stale false from an earlier key-only read can't hide "Show recovery phrase" after a phrase is added. --- __tests__/hooks/useHasRecoveryPhrase.test.ts | 115 +++++++++++++++++++ src/hooks/useHasRecoveryPhrase.ts | 3 + 2 files changed, 118 insertions(+) create mode 100644 __tests__/hooks/useHasRecoveryPhrase.test.ts diff --git a/__tests__/hooks/useHasRecoveryPhrase.test.ts b/__tests__/hooks/useHasRecoveryPhrase.test.ts new file mode 100644 index 000000000..b62976334 --- /dev/null +++ b/__tests__/hooks/useHasRecoveryPhrase.test.ts @@ -0,0 +1,115 @@ +import { act, renderHook } from "@testing-library/react-native"; +import { SecretSourceMap, SecretSourceType } from "config/types"; +import { useHasRecoveryPhrase } from "hooks/useHasRecoveryPhrase"; +import { getSecretSources } from "services/auth/secretSources"; + +// Runs the focus effect on mount, and lets a test leave and come back into +// focus: the cleanup runs, then the effect again. +let mockRefocus: () => void = () => {}; +let mockBlur: () => void = () => {}; +jest.mock("@react-navigation/native", () => { + const { useEffect } = jest.requireActual("react"); + return { + useFocusEffect: (effect: () => (() => void) | undefined) => { + useEffect(() => { + let cleanup = effect(); + mockBlur = () => cleanup?.(); + mockRefocus = () => { + cleanup?.(); + cleanup = effect(); + }; + return () => cleanup?.(); + }, [effect]); + }, + }; +}); + +jest.mock("config/logger", () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +jest.mock("services/auth/secretSources", () => ({ + getSecretSources: jest.fn(), +})); + +const sources = (...types: SecretSourceType[]): SecretSourceMap => + Object.fromEntries( + types.map((type, index) => [ + `source-${index}`, + { id: `source-${index}`, type, label: `Source ${index}`, createdAt: 1 }, + ]), + ); + +const mockRead = getSecretSources as jest.Mock; + +/** Lets a pending sources read settle. */ +const settle = () => act(async () => {}); + +describe("useHasRecoveryPhrase", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("is true until the sources have been read", () => { + mockRead.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useHasRecoveryPhrase()); + + expect(result.current).toBe(true); + }); + + it("is false for a wallet holding only secret keys", async () => { + mockRead.mockResolvedValue(sources(SecretSourceType.SECRET_KEY)); + + const { result } = renderHook(() => useHasRecoveryPhrase()); + await settle(); + + expect(result.current).toBe(false); + }); + + it("is true once the wallet holds a phrase", async () => { + mockRead.mockResolvedValue( + sources(SecretSourceType.SECRET_KEY, SecretSourceType.PHRASE), + ); + + const { result } = renderHook(() => useHasRecoveryPhrase()); + await settle(); + + expect(result.current).toBe(true); + }); + + // A phrase may have been added since the key-only read, so a failed read + // must not leave the row hidden. + it("goes back to true when a later read fails", async () => { + mockRead.mockResolvedValueOnce(sources(SecretSourceType.SECRET_KEY)); + const { result } = renderHook(() => useHasRecoveryPhrase()); + await settle(); + expect(result.current).toBe(false); + + mockRead.mockRejectedValueOnce(new Error("read failed")); + act(() => { + mockRefocus(); + }); + await settle(); + + expect(result.current).toBe(true); + }); + + it("ignores a read that settles after the screen lost focus", async () => { + let finishRead: (map: SecretSourceMap) => void = () => {}; + mockRead.mockReturnValue( + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const { result } = renderHook(() => useHasRecoveryPhrase()); + + act(() => { + mockBlur(); + }); + finishRead(sources(SecretSourceType.SECRET_KEY)); + await settle(); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/hooks/useHasRecoveryPhrase.ts b/src/hooks/useHasRecoveryPhrase.ts index 76ff80cef..0b0214e36 100644 --- a/src/hooks/useHasRecoveryPhrase.ts +++ b/src/hooks/useHasRecoveryPhrase.ts @@ -27,6 +27,9 @@ export const useHasRecoveryPhrase = (): boolean => { "Failed to read secret sources", error, ); + // Back to the permissive default: a false from an earlier read + // may be stale, e.g. a phrase was added since. + if (isActive) setHasPhrase(true); }); return () => { From bf62fc1aa9b7be96299ec343de4984b823911131 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 13 Nov 2026 16:03:06 -0700 Subject: [PATCH 14/14] fix(auth): never sign as a conflicting source when the pin is missing --- .../services/auth/signingIdentity.test.ts | 60 +++++++++++++++++++ src/services/auth/signingIdentity.ts | 21 +++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/__tests__/services/auth/signingIdentity.test.ts b/__tests__/services/auth/signingIdentity.test.ts index ec57526cb..48c1c6c7f 100644 --- a/__tests__/services/auth/signingIdentity.test.ts +++ b/__tests__/services/auth/signingIdentity.test.ts @@ -244,6 +244,66 @@ describe("getSigningIdentity", () => { expect((await getSigningIdentity())?.sourceId).toBe(phraseAId); }); + describe("when the pinned source is not held", () => { + // A phrase and an imported key, neither of them the pinned source. + const unlockWithoutPin = async (status: Record) => { + await unlock({ + accounts: { "key-imported": importedSecret }, + mnemonics: { [phraseAId]: phraseA.mnemonic }, + order: [], + }); + await setPinnedSourceId("f".repeat(64)); + memory[STORAGE_KEYS.SOURCE_LINK_STATUS] = JSON.stringify(status); + }; + + it("signs with a linked source over a phrase another user owns", async () => { + await unlockWithoutPin({ + [phraseAId]: "conflict", + [importedId]: "linked", + }); + + expect((await getSigningIdentity())?.sourceId).toBe(importedId); + expect(await getPinnedSourceId()).toBe("f".repeat(64)); + }); + + it("signs with an unconfirmed source over one another user owns", async () => { + await unlockWithoutPin({ [phraseAId]: "conflict" }); + + expect((await getSigningIdentity())?.sourceId).toBe(importedId); + }); + + it("prefers a linked source over an unconfirmed phrase", async () => { + await unlockWithoutPin({ [importedId]: "linked" }); + + expect((await getSigningIdentity())?.sourceId).toBe(importedId); + }); + + it("returns null when every held source belongs to another user", async () => { + await unlockWithoutPin({ + [phraseAId]: "conflict", + [importedId]: "conflict", + }); + + expect(await getSigningIdentity()).toBeNull(); + }); + }); + + it("signs with the held pinned source whatever the link status says", async () => { + await unlock({ + accounts: { + "key-a0": phraseA.accounts[0].secret, + "key-imported": importedSecret, + }, + mnemonics: { [phraseAId]: phraseA.mnemonic }, + order: [phrase, key], + }); + memory[STORAGE_KEYS.SOURCE_LINK_STATUS] = JSON.stringify({ + [importedId]: "linked", + }); + + expect((await getSigningIdentity())?.sourceId).toBe(phraseAId); + }); + it("signs as the earliest registered source, without pinning it, when the wallet has no pin", async () => { // Held sources list phrases first, so the key is not the first held one. await unlock({ diff --git a/src/services/auth/signingIdentity.ts b/src/services/auth/signingIdentity.ts index d8dbabbd0..7f19dc84c 100644 --- a/src/services/auth/signingIdentity.ts +++ b/src/services/auth/signingIdentity.ts @@ -1,6 +1,7 @@ import { Keypair } from "@stellar/stellar-sdk"; import { logger } from "config/logger"; import { SecretSourceType } from "config/types"; +import { getLinkStatus } from "services/auth/linkStatus"; import { getFirstSourceId, getPinnedSourceId, @@ -43,9 +44,14 @@ export interface SigningIdentity { * - The pinned source is held: sign with it. With no pin, as after a failed * pin write, the earliest registered source stands in for it (the source * the next registration or unlock will pin). - * - That source is not held, as after a restore that recovered only some - * sources: sign with the first held source (phrases first) and leave the - * pin alone. The pin moves only from what the server returns. + * - That source is not held, as when it failed to derive this unlock: sign + * with a held source the server listed in this wallet's cluster + * ("linked"), else one it has not answered for yet, phrases first in each + * case. Never sign with a source the server reported as another user's + * ("conflict"): that would speak as that user. With only those held, + * return null. The pin stays put, so a source that failed to derive this + * unlock cannot move it; moving it waits for account removal, the first + * way a pinned source can leave the wallet for good. * - Nothing pinned and nothing registered, as for an upgraded wallet before * its first sign-in: sign with the first held phrase, the source the * migration will pin, or return null when no phrase derived. Nothing is @@ -81,8 +87,13 @@ export const getSigningIdentity = async (): Promise => { "The pinned source is not held", new Error("The pinned source is not held"), ); - const [{ id: firstHeldId }] = sources; - return { sourceId: firstHeldId, keypair: signers.get(firstHeldId)! }; + const status = await getLinkStatus(); + const fallback = + sources.find(({ id }) => status[id] === "linked") ?? + sources.find(({ id }) => status[id] === undefined); + return fallback + ? { sourceId: fallback.id, keypair: signers.get(fallback.id)! } + : null; } catch (error) { // A locked or empty wallet is handled above, so anything reaching here // is unexpected (a PBKDF2 failure, a storage read error). Log it: