diff --git a/__tests__/components/screens/AddAnotherWallet.test.tsx b/__tests__/components/screens/AddAnotherWallet.test.tsx
new file mode 100644
index 000000000..8596be251
--- /dev/null
+++ b/__tests__/components/screens/AddAnotherWallet.test.tsx
@@ -0,0 +1,47 @@
+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-phrase",
+ MANAGE_WALLETS_ROUTES.IMPORT_RECOVERY_PHRASE_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(getByText("Import Recovery Phrase")).toBeTruthy();
+ 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/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx b/__tests__/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.test.tsx
index ad1badab4..355a9dfc9 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";
@@ -18,20 +21,29 @@ jest.mock("services/storage/storageFactory", () => ({
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,
}),
}));
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,
}),
@@ -80,10 +92,24 @@ 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",
+ secretKey: null,
+ };
useWalletSetupOverlayStore.setState(
useWalletSetupOverlayStore.getInitialState(),
true,
@@ -171,6 +197,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 +278,67 @@ 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();
+ });
+
+ // 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);
@@ -294,7 +399,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 () => {
@@ -377,6 +482,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");
});
@@ -392,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__/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/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 2541df0d0..2ba445a16 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.
@@ -211,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/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__/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/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/AccountsRecoveryScreen.test.tsx b/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx
new file mode 100644
index 000000000..a4edf19f9
--- /dev/null
+++ b/__tests__/components/screens/SettingsScreen/AccountsRecoveryScreen.test.tsx
@@ -0,0 +1,150 @@
+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";
+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("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("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();
+
+ 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..8a4f3e10f
--- /dev/null
+++ b/__tests__/components/screens/SettingsScreen/EditAccountScreen.test.tsx
@@ -0,0 +1,164 @@
+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 }),
+}));
+
+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 ({ isBottomSheetInput }: { isBottomSheetInput?: boolean }) => (
+ {String(isBottomSheetInput)}
+ );
+});
+
+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(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", () => {
+ 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/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/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__/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__/components/screens/VerifyPasswordScreen.test.tsx b/__tests__/components/screens/VerifyPasswordScreen.test.tsx
new file mode 100644
index 000000000..61bab120f
--- /dev/null
+++ b/__tests__/components/screens/VerifyPasswordScreen.test.tsx
@@ -0,0 +1,137 @@
+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();
+const mockImportRecoveryPhrase = jest.fn();
+jest.mock("ducks/auth", () => ({
+ useAuthenticationStore: () => ({
+ createAccount: mockCreateAccount,
+ addRecoveryPhraseSource: mockAddRecoveryPhraseSource,
+ importRecoveryPhrase: mockImportRecoveryPhrase,
+ 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,
+ continueButtonText,
+ }: {
+ handleContinue: (password: string) => void;
+ continueButtonText: string;
+ }) => (
+ handleContinue("hunter2")}
+ />
+ );
+});
+
+const navigation = { reset: jest.fn() };
+
+const renderScreen = (params: CreateWalletTarget) =>
+ renderWithProviders(
+ ,
+ );
+
+describe("VerifyPasswordScreen", () => {
+ beforeEach(() => {
+ 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 () => {
+ 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 7cccca899..3bbb4730c 100644
--- a/__tests__/ducks/auth.test.ts
+++ b/__tests__/ducks/auth.test.ts
@@ -1,12 +1,14 @@
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,
NETWORKS,
STORAGE_KEYS,
SENSITIVE_STORAGE_KEYS,
+ BIOMETRIC_STORAGE_KEYS,
LoginType,
} from "config/constants";
import { logger } from "config/logger";
@@ -21,7 +23,13 @@ import {
clearSessionAuthValidMemo,
SESSION_AUTH_VALID_TTL_MS,
getActiveMnemonicPhrase,
+ revealAccountSecretKey,
+ revealRecoveryPhrase,
getHeldSecrets,
+ generatePendingRecoveryPhrase,
+ getPendingRecoveryPhrase,
+ clearPendingRecoveryPhrase,
+ holdPendingRecoveryPhrase,
} from "ducks/auth";
import { useBalancesStore } from "ducks/balances";
import { useCollectiblesStore } from "ducks/collectibles";
@@ -39,7 +47,9 @@ 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";
import { linkWalletSources } from "services/auth/linkSources";
import { clearLinkStatus, getLinkConflicts } from "services/auth/linkStatus";
@@ -116,6 +126,7 @@ jest.mock("services/storage/storageFactory", () => ({
getItem: jest.fn(),
setItem: jest.fn(),
checkIfExists: jest.fn(),
+ remove: jest.fn(),
},
}));
@@ -139,6 +150,8 @@ jest.mock("stellar-hd-wallet", () => ({
__esModule: true,
default: {
fromMnemonic: jest.fn(),
+ generateMnemonic: jest.fn(),
+ validateMnemonic: jest.fn(() => true),
},
}));
@@ -338,6 +351,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,
@@ -346,6 +361,10 @@ describe("auth duck", () => {
importSecretKey: useAuthenticationStore.getState().importSecretKey,
renameAccount: useAuthenticationStore.getState().renameAccount,
createAccount: useAuthenticationStore.getState().createAccount,
+ addRecoveryPhraseSource:
+ useAuthenticationStore.getState().addRecoveryPhraseSource,
+ importRecoveryPhrase:
+ useAuthenticationStore.getState().importRecoveryPhrase,
initializeNetwork: useAuthenticationStore.getState().initializeNetwork,
softLock: useAuthenticationStore.getState().softLock,
navigateToLockScreen:
@@ -360,6 +379,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();
@@ -2874,6 +2894,7 @@ describe("auth duck", () => {
storeKey: jest.Mock;
loadKey: jest.Mock;
loadAllKeyIds: jest.Mock;
+ removeKey: jest.Mock;
};
}>("helpers/keyManager/keyManager");
@@ -3053,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;
@@ -3118,6 +3202,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;
@@ -3159,8 +3327,8 @@ describe("auth duck", () => {
);
});
- describe("signIn", () => {
- beforeEach(() => {
+ describe("importWalletFromSecretKey", () => {
+ const previousSources = () => {
dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId;
dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([
{
@@ -3169,130 +3337,533 @@ describe("auth duck", () => {
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({
- authStatus: AUTH_STATUS.HASH_KEY_EXPIRED,
- signIn: originalStoreMethods.signIn,
+ importWalletFromSecretKey:
+ originalStoreMethods.importWalletFromSecretKey,
});
});
- });
-
- it("rebuilds the session with the keystore's phrase held under its source id", async () => {
- const { result } = renderHook(() => useAuthenticationStore());
-
+ let succeeded = false;
await act(async () => {
- await result.current.signIn({ password: mockPassword });
+ succeeded = await result.current.importWalletFromSecretKey({
+ secretKey,
+ password: mockPassword,
+ });
});
+ return { result, succeeded };
+ };
- const [written] = writtenTemporaryStores();
- expect(written.mnemonics).toEqual({
- [mockPhraseSourceId]: mockMnemonicPhrase,
- });
- expect(written.privateKeys).toEqual({
- [mockAccountId]: mockPrivateKey,
+ beforeEach(() => {
+ wipeNonSensitiveData();
+ sharedKeyManager.storeKey.mockResolvedValue({
+ id: "imported-account-id",
});
});
- it("keeps the session, holding no phrase, when the phrase's source id cannot be derived", async () => {
- (deriveAuthKeypair as jest.Mock).mockRejectedValueOnce(
- new Error("Invalid mnemonic (see bip39)"),
- );
- const { result } = renderHook(() => useAuthenticationStore());
+ it("replaces the wallet with one imported account and no phrase", async () => {
+ previousSources();
- await act(async () => {
- await result.current.signIn({ password: mockPassword });
- });
+ 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).toEqual({
- [mockAccountId]: mockPrivateKey,
- });
- expect(logger.error).toHaveBeenCalledWith(
- "signIn",
- "Failed to derive the phrase source id on unlock",
- expect.any(Error),
+ expect(written.privateKeys["imported-account-id"]).toBe(
+ importedSecretKey,
+ );
+ expect(linkWalletSources).toHaveBeenCalledTimes(1);
+ expect(analytics.track).toHaveBeenCalledWith(
+ AnalyticsEvent.RECOVER_ACCOUNT_SUCCESS,
+ { recovery_method: "secret_key" },
+ );
+ expect(biometricDataStorage.setItem).toHaveBeenCalledWith(
+ BIOMETRIC_STORAGE_KEYS.BIOMETRIC_PASSWORD,
+ mockPassword,
);
+ expect(biometricDataStorage.remove).not.toHaveBeenCalled();
});
- // The request path never writes the pin, so unlock repairs a pin write
- // that failed after its source record landed.
- it("pins the earliest registered source when the wallet has records but no pin", async () => {
- const firstId = "a".repeat(64);
- const secondId = "b".repeat(64);
- dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES] = JSON.stringify({
- [firstId]: { id: firstId, type: SecretSourceType.SECRET_KEY },
- [secondId]: { id: secondId, type: SecretSourceType.PHRASE },
- });
- // Already attributed, so the attribution pass registers nothing and
- // only the repair can pin.
- dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([
- {
- id: mockAccountId,
- name: mockAccountName,
- publicKey: mockPublicKey,
- sourceId: firstId,
- },
- ]);
- const { result } = renderHook(() => useAuthenticationStore());
+ it("leaves the wallet untouched when the key is invalid", async () => {
+ previousSources();
+ const before = { ...dataStorageMemory };
- await act(async () => {
- await result.current.signIn({ password: mockPassword });
+ 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" },
+ );
+ // 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,
+ );
+ });
- expect(dataStorageMemory[STORAGE_KEYS.PINNED_SOURCE_ID]).toBe(firstId);
+ // 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);
});
- });
- // Port of the extension's wallet source enumeration (#65): unlock holds
- // every account's private key and warms the source identities.
- describe("wallet sources", () => {
- const secondAccount = {
- id: "second-account-id",
- name: "Account 2",
- publicKey: importedPublicKey,
- sourceId: `key:${importedSecretKey}`,
- importedFromSecretKey: true,
- };
- const thirdAccount = {
- id: "third-account-id",
- name: "Account 3",
- publicKey: "GTHIRD",
- sourceId: "key:SCTHIRD",
- importedFromSecretKey: true,
- };
+ 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();
- // LOCKED fast path: the session holds only the active account's key.
- const lockedWithAccounts = (accounts: Array<{ id: string }>) => {
- dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId;
- dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([
- {
- id: mockAccountId,
- name: mockAccountName,
- publicKey: mockPublicKey,
- sourceId: mockPhraseSourceId,
+ 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);
+ // 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],
+ ).toBeUndefined();
+ expect(analytics.track).toHaveBeenCalledWith(
+ AnalyticsEvent.RECOVER_ACCOUNT_FAIL,
+ { 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();
},
- ...accounts,
- ]);
- mockAuthenticatedSession(mockTemporaryStore);
+ );
+ const { result } = renderHook(() => useAuthenticationStore());
act(() => {
useAuthenticationStore.setState({
- authStatus: AUTH_STATUS.LOCKED,
- signIn: originalStoreMethods.signIn,
+ importWalletFromSecretKey:
+ originalStoreMethods.importWalletFromSecretKey,
});
});
- };
- const keystoreFor = (id: string) => {
- if (id === secondAccount.id) {
- return {
- id,
- publicKey: importedPublicKey,
- privateKey: importedSecretKey,
- };
- }
- if (id === thirdAccount.id) {
- return { id, publicKey: "GTHIRD", privateKey: "SCTHIRD" };
+ 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 () => {
+ sharedKeyManager.storeKey.mockRejectedValue(
+ new Error(`could not store key_${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;
+ dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([
+ {
+ id: mockAccountId,
+ name: mockAccountName,
+ publicKey: mockPublicKey,
+ },
+ ]);
+ act(() => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.HASH_KEY_EXPIRED,
+ signIn: originalStoreMethods.signIn,
+ });
+ });
+ });
+
+ it("rebuilds the session with the keystore's phrase held under its source id", async () => {
+ const { result } = renderHook(() => useAuthenticationStore());
+
+ await act(async () => {
+ await result.current.signIn({ password: mockPassword });
+ });
+
+ const [written] = writtenTemporaryStores();
+ expect(written.mnemonics).toEqual({
+ [mockPhraseSourceId]: mockMnemonicPhrase,
+ });
+ expect(written.privateKeys).toEqual({
+ [mockAccountId]: mockPrivateKey,
+ });
+ });
+
+ it("keeps the session, holding no phrase, when the phrase's source id cannot be derived", async () => {
+ (deriveAuthKeypair as jest.Mock).mockRejectedValueOnce(
+ new Error("Invalid mnemonic (see bip39)"),
+ );
+ const { result } = renderHook(() => useAuthenticationStore());
+
+ await act(async () => {
+ await result.current.signIn({ password: mockPassword });
+ });
+
+ const [written] = writtenTemporaryStores();
+ expect(written.mnemonics).toEqual({});
+ expect(written.privateKeys).toEqual({
+ [mockAccountId]: mockPrivateKey,
+ });
+ expect(logger.error).toHaveBeenCalledWith(
+ "signIn",
+ "Failed to derive the phrase source id on unlock",
+ expect.any(Error),
+ );
+ });
+
+ // The request path never writes the pin, so unlock repairs a pin write
+ // that failed after its source record landed.
+ it("pins the earliest registered source when the wallet has records but no pin", async () => {
+ const firstId = "a".repeat(64);
+ const secondId = "b".repeat(64);
+ dataStorageMemory[STORAGE_KEYS.SECRET_SOURCES] = JSON.stringify({
+ [firstId]: { id: firstId, type: SecretSourceType.SECRET_KEY },
+ [secondId]: { id: secondId, type: SecretSourceType.PHRASE },
+ });
+ // Already attributed, so the attribution pass registers nothing and
+ // only the repair can pin.
+ dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([
+ {
+ id: mockAccountId,
+ name: mockAccountName,
+ publicKey: mockPublicKey,
+ sourceId: firstId,
+ },
+ ]);
+ const { result } = renderHook(() => useAuthenticationStore());
+
+ await act(async () => {
+ await result.current.signIn({ password: mockPassword });
+ });
+
+ expect(dataStorageMemory[STORAGE_KEYS.PINNED_SOURCE_ID]).toBe(firstId);
+ });
+ });
+
+ // Port of the extension's wallet source enumeration (#65): unlock holds
+ // every account's private key and warms the source identities.
+ describe("wallet sources", () => {
+ const secondAccount = {
+ id: "second-account-id",
+ name: "Account 2",
+ publicKey: importedPublicKey,
+ sourceId: `key:${importedSecretKey}`,
+ importedFromSecretKey: true,
+ };
+ const thirdAccount = {
+ id: "third-account-id",
+ name: "Account 3",
+ publicKey: "GTHIRD",
+ sourceId: "key:SCTHIRD",
+ importedFromSecretKey: true,
+ };
+
+ // LOCKED fast path: the session holds only the active account's key.
+ const lockedWithAccounts = (accounts: Array<{ id: string }>) => {
+ dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId;
+ dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([
+ {
+ id: mockAccountId,
+ name: mockAccountName,
+ publicKey: mockPublicKey,
+ sourceId: mockPhraseSourceId,
+ },
+ ...accounts,
+ ]);
+ mockAuthenticatedSession(mockTemporaryStore);
+ act(() => {
+ useAuthenticationStore.setState({
+ authStatus: AUTH_STATUS.LOCKED,
+ signIn: originalStoreMethods.signIn,
+ });
+ });
+ };
+
+ const keystoreFor = (id: string) => {
+ if (id === secondAccount.id) {
+ return {
+ id,
+ publicKey: importedPublicKey,
+ privateKey: importedSecretKey,
+ };
+ }
+ if (id === thirdAccount.id) {
+ return { id, publicKey: "GTHIRD", privateKey: "SCTHIRD" };
}
return {
id: mockAccountId,
@@ -3767,50 +4338,278 @@ describe("auth duck", () => {
});
});
- it("derives a missing private key from any held phrase on account switch", async () => {
- const secondAccount: Account = {
- id: "account-2",
- name: "Account 2",
- publicKey: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
- };
- mockAuthenticatedSession(
- JSON.stringify({
- privateKeys: { [mockAccountId]: mockPrivateKey },
- mnemonics: { "id-a": "phrase a", "id-b": "phrase b" },
- }),
- );
- dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId;
- dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([
- { id: mockAccountId, name: mockAccountName, publicKey: mockPublicKey },
- secondAccount,
- ]);
- // Only the second phrase derives the second account.
- (StellarHDWallet.fromMnemonic as jest.Mock).mockImplementation(
- (mnemonic: string) => ({
- getPublicKey: jest.fn(() =>
- mnemonic === "phrase b"
- ? secondAccount.publicKey
- : "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
- ),
- getSecret: jest.fn(() => "derived-private-key-2"),
- }),
- );
- const { result } = renderHook(() => useAuthenticationStore());
- act(() => {
- useAuthenticationStore.setState({
- account: mockAccount,
- selectAccount: originalStoreMethods.selectAccount,
- });
- });
+ // Edit account's reveals: the password is checked against a keystore
+ // even though the session is unlocked.
+ describe("revealAccountSecretKey", () => {
+ const importedId = "imported-keystore-id";
- await act(async () => {
- await result.current.selectAccount(secondAccount.publicKey);
+ 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 },
+ );
+ },
+ );
});
- expect(result.current.account?.id).toBe(secondAccount.id);
- expect(result.current.account?.privateKey).toBe("derived-private-key-2");
- expect(StellarHDWallet.fromMnemonic).toHaveBeenCalledWith("phrase a");
- expect(StellarHDWallet.fromMnemonic).toHaveBeenCalledWith("phrase b");
+ 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",
+ name: "Account 2",
+ publicKey: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+ };
+ mockAuthenticatedSession(
+ JSON.stringify({
+ privateKeys: { [mockAccountId]: mockPrivateKey },
+ mnemonics: { "id-a": "phrase a", "id-b": "phrase b" },
+ }),
+ );
+ dataStorageMemory[STORAGE_KEYS.ACTIVE_ACCOUNT_ID] = mockAccountId;
+ dataStorageMemory[STORAGE_KEYS.ACCOUNT_LIST] = JSON.stringify([
+ { id: mockAccountId, name: mockAccountName, publicKey: mockPublicKey },
+ secondAccount,
+ ]);
+ // Only the second phrase derives the second account.
+ (StellarHDWallet.fromMnemonic as jest.Mock).mockImplementation(
+ (mnemonic: string) => ({
+ getPublicKey: jest.fn(() =>
+ mnemonic === "phrase b"
+ ? secondAccount.publicKey
+ : "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ ),
+ getSecret: jest.fn(() => "derived-private-key-2"),
+ }),
+ );
+ const { result } = renderHook(() => useAuthenticationStore());
+ act(() => {
+ useAuthenticationStore.setState({
+ account: mockAccount,
+ selectAccount: originalStoreMethods.selectAccount,
+ });
+ });
+
+ await act(async () => {
+ await result.current.selectAccount(secondAccount.publicKey);
+ });
+
+ expect(result.current.account?.id).toBe(secondAccount.id);
+ expect(result.current.account?.privateKey).toBe("derived-private-key-2");
+ expect(StellarHDWallet.fromMnemonic).toHaveBeenCalledWith("phrase a");
+ expect(StellarHDWallet.fromMnemonic).toHaveBeenCalledWith("phrase b");
// The derived key joins the session; the held phrases are untouched.
const [written] = writtenTemporaryStores();
expect(written.privateKeys[secondAccount.id]).toBe(
@@ -4715,6 +5514,772 @@ 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({
+ 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);
+ });
+ });
+
+ 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__/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/__tests__/helpers/accountGroups.test.ts b/__tests__/helpers/accountGroups.test.ts
new file mode 100644
index 000000000..cb00f8d08
--- /dev/null
+++ b/__tests__/helpers/accountGroups.test.ts
@@ -0,0 +1,153 @@
+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"]);
+ });
+
+ // 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")],
+ 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/__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/__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/__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/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/__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 cea2a9fbb..ddd9e27d3 100644
--- a/__tests__/helpers/recoveryPhrase.test.ts
+++ b/__tests__/helpers/recoveryPhrase.test.ts
@@ -1,4 +1,6 @@
import {
+ isValidImportMnemonic,
+ normalizeImportSecretInput,
normalizeRecoveryPhrase,
normalizeAndTrimRecoveryPhrase,
} from "helpers/recoveryPhrase";
@@ -97,4 +99,49 @@ 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);
+ });
+ });
+
+ 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__/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/__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/__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/__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/__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/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/assets/images/accounts-card-avatar-back.png b/src/assets/images/accounts-card-avatar-back.png
new file mode 100644
index 000000000..c7ffea26b
Binary files /dev/null and b/src/assets/images/accounts-card-avatar-back.png differ
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 000000000..a9e06eae5
Binary files /dev/null and b/src/assets/images/accounts-card-avatar-back@2x.png differ
diff --git a/src/assets/images/accounts-card-avatar-back@3x.png b/src/assets/images/accounts-card-avatar-back@3x.png
new file mode 100644
index 000000000..52ad82e9f
Binary files /dev/null and b/src/assets/images/accounts-card-avatar-back@3x.png differ
diff --git a/src/assets/images/accounts-card-avatar-front.png b/src/assets/images/accounts-card-avatar-front.png
new file mode 100644
index 000000000..01f6507be
Binary files /dev/null and b/src/assets/images/accounts-card-avatar-front.png differ
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 000000000..3374fb692
Binary files /dev/null and b/src/assets/images/accounts-card-avatar-front@2x.png differ
diff --git a/src/assets/images/accounts-card-avatar-front@3x.png b/src/assets/images/accounts-card-avatar-front@3x.png
new file mode 100644
index 000000000..7deef527d
Binary files /dev/null and b/src/assets/images/accounts-card-avatar-front@3x.png differ
diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts
index 65f3b1dc0..e8cee1c3d 100644
--- a/src/assets/images/index.ts
+++ b/src/assets/images/index.ts
@@ -1,10 +1,14 @@
/* eslint-disable @fnando/consistent-import/consistent-import */
// Export all image assets from this file for easy importing
+import accountsCardAvatarBackImage from "./accounts-card-avatar-back.png";
+import accountsCardAvatarFrontImage from "./accounts-card-avatar-front.png";
import iPhoneFrameImage from "./iphone-frame.png";
import walletSetupSparkleDarkImage from "./wallet-setup-sparkle-dark.png";
import walletSetupSparkleLightImage from "./wallet-setup-sparkle-light.png";
export const images = {
+ accountsCardAvatarBack: accountsCardAvatarBackImage,
+ accountsCardAvatarFront: accountsCardAvatarFrontImage,
iPhoneFrame: iPhoneFrameImage,
walletSetupSparkleDark: walletSetupSparkleDarkImage,
walletSetupSparkleLight: walletSetupSparkleLightImage,
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/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/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..e8dfc7c97 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,84 @@ 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:
+ * 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,
}) => {
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.importRecoveryPhrase")}
+ description={t(
+ "addAnotherWalletScreen.actions.importRecoveryPhraseDescription",
+ )}
+ onPress={() =>
+ navigation.navigate(
+ MANAGE_WALLETS_ROUTES.IMPORT_RECOVERY_PHRASE_SCREEN,
+ )
+ }
+ testID="add-wallet-import-phrase"
+ />
+
+ }
+ 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/BiometricsEnableScreen/BiometricsEnableScreen.tsx b/src/components/screens/BiometricsEnableScreen/BiometricsEnableScreen.tsx
index abc90bd8b..9d3dec419 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,14 @@ export const BiometricsOnboardingScreen: React.FC<
isLoading,
signUp,
importWallet,
+ importWalletFromSecretKey,
+ storeBiometricPassword,
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,16 +220,24 @@ 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;
}
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) =>
@@ -232,19 +247,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 +283,12 @@ export const BiometricsOnboardingScreen: React.FC<
setIsBiometricsEnabled,
signUp,
importWallet,
+ importWalletFromSecretKey,
+ storeBiometricPassword,
enableBiometricsAction,
navigation,
mnemonicPhrase,
+ secretKey,
password,
clearLoginData,
notifySetupFailed,
@@ -293,25 +307,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 +353,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/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 934e4d609..635f3b250 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 {
@@ -18,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;
@@ -35,7 +35,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 +50,6 @@ const AccountItemRow: React.FC = ({
testID,
}) => {
const { themeColors } = useColors();
- const { t } = useAppTranslation();
const truncatedPublicKey = truncateAddress(account.publicKey);
const showSelectedBadge =
@@ -73,21 +72,21 @@ 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)
.join(", ");
const handleSelectAccountPress = useCallback(() => {
- handleSelectAccount(account.publicKey);
+ handleSelectAccount?.(account.publicKey);
}, [account.publicKey, handleSelectAccount]);
+ const isSelectable = !!handleSelectAccount;
const renderFiatTotal = () => {
if (isTotalLoading) {
@@ -115,16 +114,17 @@ const AccountItemRow: React.FC = ({
>
@@ -144,18 +144,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
new file mode 100644
index 000000000..21cd87546
--- /dev/null
+++ b/src/components/screens/HomeScreen/RenameAccountForm.tsx
@@ -0,0 +1,45 @@
+import AccountNameForm from "components/AccountNameForm";
+import { Account } from "config/types";
+import useAppTranslation from "hooks/useAppTranslation";
+import React from "react";
+
+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: the account name form with Cancel / Save name. Edit
+ * account shows it in a bottom sheet.
+ */
+const RenameAccountForm: React.FC = ({
+ account,
+ isRenamingAccount,
+ onCancel,
+ onSave,
+ isBottomSheetInput = false,
+}) => {
+ const { t } = useAppTranslation();
+
+ return (
+
+ );
+};
+
+export default RenameAccountForm;
diff --git a/src/components/screens/HomeScreen/RenameAccountModal.tsx b/src/components/screens/HomeScreen/RenameAccountModal.tsx
deleted file mode 100644
index f848c4b6a..000000000
--- a/src/components/screens/HomeScreen/RenameAccountModal.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-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 { 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";
-
-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: avatar, a single autofocused name input and
- * Cancel / Save name actions. 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,
-}) => {
- 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"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default RenameAccountModal;
diff --git a/src/components/screens/ImportRecoveryPhraseScreen.tsx b/src/components/screens/ImportRecoveryPhraseScreen.tsx
new file mode 100644
index 000000000..904d67b8e
--- /dev/null
+++ b/src/components/screens/ImportRecoveryPhraseScreen.tsx
@@ -0,0 +1,117 @@
+import { NativeStackScreenProps } from "@react-navigation/native-stack";
+import { ImportRecoveryPhraseTemplate } from "components/templates/ImportRecoveryPhraseTemplate";
+import {
+ MANAGE_WALLETS_ROUTES,
+ ManageWalletsStackParamList,
+} from "config/routes";
+import {
+ clearPendingRecoveryPhrase,
+ holdPendingRecoveryPhrase,
+} from "ducks/auth";
+import { waitForKeyboardDismiss } from "helpers/keyboard";
+import {
+ isValidImportMnemonic,
+ normalizeAndTrimRecoveryPhrase,
+} from "helpers/recoveryPhrase";
+import useAppTranslation from "hooks/useAppTranslation";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { TextInput } from "react-native";
+
+type ImportRecoveryPhraseScreenProps = NativeStackScreenProps<
+ ManageWalletsStackParamList,
+ typeof MANAGE_WALLETS_ROUTES.IMPORT_RECOVERY_PHRASE_SCREEN
+>;
+
+/**
+ * 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/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/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/SettingsScreen/AccountsRecoveryScreen/AccountsRecoveryScreen.tsx b/src/components/screens/SettingsScreen/AccountsRecoveryScreen/AccountsRecoveryScreen.tsx
new file mode 100644
index 000000000..9ce9a3f88
--- /dev/null
+++ b/src/components/screens/SettingsScreen/AccountsRecoveryScreen/AccountsRecoveryScreen.tsx
@@ -0,0 +1,239 @@
+import { useFocusEffect } from "@react-navigation/native";
+import { NativeStackScreenProps } from "@react-navigation/native-stack";
+import { BaseLayout } from "components/layout/BaseLayout";
+import Avatar from "components/sds/Avatar";
+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 { SETTINGS_ROUTES, SettingsStackParamList } from "config/routes";
+import { Account, SecretSource } from "config/types";
+import { useAccountsFiatTotalsStore } from "ducks/accountsFiatTotals";
+import { useAuthenticationStore } from "ducks/auth";
+import { AccountGroupKind, groupAccountsBySource } from "helpers/accountGroups";
+import { pxValue } from "helpers/dimensions";
+import useAppTranslation from "hooks/useAppTranslation";
+import useColors from "hooks/useColors";
+import useGetActiveAccount from "hooks/useGetActiveAccount";
+import React, { useCallback, useMemo, useState } from "react";
+import {
+ ActivityIndicator,
+ ScrollView,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { getSecretSources } from "services/auth/secretSources";
+
+type AccountsRecoveryScreenProps = NativeStackScreenProps<
+ SettingsStackParamList,
+ typeof SETTINGS_ROUTES.ACCOUNTS_RECOVERY_SCREEN
+>;
+
+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,
+ );
+ // 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(
+ useCallback(() => {
+ getAllAccounts();
+ getSecretSources()
+ .then((map) => setSources(Object.values(map)))
+ .catch((error) => {
+ logger.error(
+ "AccountsRecoveryScreen",
+ "Failed to read secret sources",
+ error,
+ );
+ // Still list the accounts, under the trailing group.
+ 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 groups = useMemo(
+ () =>
+ sources
+ ? 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..0e5b47c13
--- /dev/null
+++ b/src/components/screens/SettingsScreen/EditAccountScreen/EditAccountScreen.tsx
@@ -0,0 +1,214 @@
+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 RenameAccountForm from "components/screens/HomeScreen/RenameAccountForm";
+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, useRef, 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 renameSheetRef = useRef(null);
+
+ 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 });
+ renameSheetRef.current?.dismiss();
+ },
+ [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: () => renameSheetRef.current?.present(),
+ 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 &&
}
+
+
+
+ renameSheetRef.current?.dismiss()}
+ onSave={handleRename}
+ isBottomSheetInput
+ />
+ }
+ />
+
+ );
+};
+
+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}
+
+
+ )}
+ }
+ iconPosition={IconPosition.RIGHT}
+ >
+ {t("revealSecretScreen.tapToCopy")}
+
+
+ {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/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/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/components/screens/VerifyPasswordScreen.tsx b/src/components/screens/VerifyPasswordScreen.tsx
index bcd59d9a7..3ccc62ae7 100644
--- a/src/components/screens/VerifyPasswordScreen.tsx
+++ b/src/components/screens/VerifyPasswordScreen.tsx
@@ -14,11 +14,25 @@ type VerifyPasswordScreenProps = NativeStackScreenProps<
typeof MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN
>;
+/**
+ * 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,
+ route,
}) => {
- const { createAccount, isCreatingAccount, error, clearError } =
- useAuthenticationStore();
+ const {
+ createAccount,
+ addRecoveryPhraseSource,
+ importRecoveryPhrase,
+ isCreatingAccount,
+ error,
+ clearError,
+ } = useAuthenticationStore();
+ const target = route.params;
const [publicKey, setPublicKey] = useState(null);
const { t } = useAppTranslation();
@@ -39,15 +53,40 @@ 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 = (() => {
+ switch (target.kind) {
+ case "newPhrase":
+ return addRecoveryPhraseSource(
+ password,
+ target.pendingPhraseId,
+ target.accountName,
+ );
+ case "importPhrase":
+ return importRecoveryPhrase(password, target.pendingPhraseId);
+ default:
+ return 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,
+ importRecoveryPhrase,
+ isCreatingAccount,
+ navigation,
+ target,
+ ],
);
return (
@@ -56,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/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/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..7098ce9b3 100644
--- a/src/config/routes.ts
+++ b/src/config/routes.ts
@@ -95,12 +95,18 @@ 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 = {
ADD_ANOTHER_WALLET_SCREEN: "AddAnotherWalletScreen",
+ CREATE_WALLET_SCREEN: "CreateWalletScreen",
+ 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 = {
@@ -196,6 +202,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,12 +225,36 @@ 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;
};
+/**
+ * What the password step creates once it's confirmed: an account under an
+ * 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: "importPhrase"; pendingPhraseId: 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;
+ [MANAGE_WALLETS_ROUTES.IMPORT_RECOVERY_PHRASE_SCREEN]: undefined;
};
export type AddFundsStackParamList = {
diff --git a/src/ducks/auth.ts b/src/ducks/auth.ts
index c97873153..804a6f5b7 100644
--- a/src/ducks/auth.ts
+++ b/src/ducks/auth.ts
@@ -44,6 +44,11 @@ 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,
+} from "helpers/accountName";
import { clearAllWebViewData } from "helpers/browser";
import {
deriveKeyFromPassword,
@@ -52,9 +57,15 @@ 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 {
+ isValidImportMnemonic,
+ 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";
@@ -66,6 +77,7 @@ import {
getDerivationIndices,
lowestUnusedIndex,
markDerivationIndexUsed,
+ releaseDerivationIndex,
} from "services/auth/derivationIndices";
import { deriveAuthKeypair } from "services/auth/deriveAuthKeypair";
import { linkWalletSources } from "services/auth/linkSources";
@@ -76,7 +88,9 @@ import {
getHeldMnemonic,
getPhraseSourceId,
getSecretKeySourceId,
+ getSecretSources,
pinFirstSource,
+ removeSecretSource,
} from "services/auth/secretSources";
import { getWalletSources } from "services/auth/walletSources";
import { clearCachedWalletSources } from "services/auth/walletSourcesCache";
@@ -171,6 +185,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
*
@@ -185,6 +211,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 {
/**
@@ -215,6 +242,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;
}
/**
@@ -354,6 +386,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;
@@ -363,9 +399,32 @@ 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,
+ accountName?: string,
+ ) => Promise;
+ /**
+ * Stores the phrase `generatePendingRecoveryPhrase` is holding as a new
+ * phrase source and adds its first account, which becomes active.
*/
- createAccount: (password: string, sourceId?: string) => Promise;
+ addRecoveryPhraseSource: (
+ password: string,
+ 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;
@@ -1161,6 +1220,7 @@ const storeAccount = async ({
password,
keyPair,
accountNumber,
+ accountName: requestedAccountName,
shouldRefreshHashKey = true,
shouldSetActiveAccount = true,
importedFromSecretKey = false,
@@ -1195,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),
]);
@@ -1214,9 +1276,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
@@ -1229,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(),
@@ -1268,27 +1340,113 @@ const deriveKeyPair = (params: DeriveKeypairParams) => {
};
};
+/*
+ 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
+ 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;
+
+/**
+ * Holds a phrase the user typed until `importRecoveryPhrase` stores it, in
+ * the same slot a generated phrase uses. A second call replaces it.
+ *
+ * @param {string} mnemonicPhrase - The phrase, already normalized
+ * @returns The id of this hold, for the screens to pass along
+ */
+export const holdPendingRecoveryPhrase = (mnemonicPhrase: string): string => {
+ pendingRecoveryPhraseCount += 1;
+ pendingRecoveryPhrase = {
+ mnemonicPhrase,
+ // Unique within the process, which is all it has to be: the phrase never
+ // outlives it.
+ id: `pending-phrase-${pendingRecoveryPhraseCount}`,
+ };
+ 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.
+ *
+ * @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
*
+ * 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 => {
clearDerivedKeyCache();
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();
+ }
};
/**
@@ -1342,6 +1500,69 @@ 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));
+};
+
+/** 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.
@@ -1402,9 +1623,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);
@@ -2218,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
*
@@ -2235,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();
@@ -2254,8 +2498,7 @@ const signUp = async ({
});
} catch (error) {
// Clean up any partial data on error
- clearAccountData();
- await clearAllData();
+ await rollBackWalletSetup();
throw error;
}
@@ -2278,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();
@@ -2321,10 +2568,76 @@ const importWallet = async ({
reason_code: scrubStrKeys(importFailureMessage) ?? importFailureMessage,
});
// Clean up any partial data on error
+ await rollBackWalletSetup();
+
+ throw error;
+ }
+};
+
+/**
+ * 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 {
+ // 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();
- throw error;
+ 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 {
+ // 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: "store_failed",
+ });
+ // The previous wallet is already gone, so drop the partial writes too.
+ await rollBackWalletSetup();
+
+ throw new Error(t("authStore.error.failedToImportWallet"));
}
};
@@ -2439,6 +2752,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
*
@@ -2513,12 +2862,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
@@ -2528,12 +2872,7 @@ const getActiveAccount = async (
if (loadedKey) {
privateKey = loadedKey;
- await updateTemporaryStoreWithPrivateKey(
- temporaryStore,
- activeAccountId,
- privateKey,
- hashKey,
- );
+ await cachePrivateKey(authStatus, activeAccountId, privateKey);
}
}
} catch (e) {
@@ -2613,12 +2952,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
@@ -2712,8 +3053,236 @@ 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();
+};
+
+/** 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);
};
/**
@@ -2727,11 +3296,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, {
@@ -2740,6 +3313,7 @@ const importSecretKeyLocal = async (
});
if (accountExists) {
+ reasonCode = "account_already_exists";
throw new Error(t("authStore.error.accountAlreadyExists"));
}
@@ -2748,6 +3322,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"));
}
@@ -2775,13 +3350,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;
}
};
@@ -2825,12 +3394,15 @@ 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"),
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"),
@@ -2930,6 +3502,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
@@ -3020,19 +3593,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 });
}
@@ -3060,6 +3644,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
@@ -3105,10 +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 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);
@@ -3161,18 +3745,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
@@ -3604,10 +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 resetAutoLockForNewWallet();
- await importWallet(params);
+ // importWallet resets auto-lock itself, once the phrase is known good.
+ await withAccountMutationLock(() => importWallet(params));
set({
...initialState,
navigationRef: get().navigationRef,
@@ -3637,6 +4227,48 @@ 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 {
+ // importWalletFromSecretKey resets auto-lock itself, once the key is
+ // known good.
+ await withAccountMutationLock(() => 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.
*
@@ -3809,7 +4441,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) {
@@ -3854,11 +4486,17 @@ 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 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.
@@ -3896,6 +4534,100 @@ export const useAuthenticationStore = create()((set, get) => ({
}
},
+ addRecoveryPhraseSource: async (
+ password: string,
+ pendingPhraseId: string,
+ accountName?: string,
+ ) => {
+ set({ isCreatingAccount: true, error: null });
+
+ try {
+ await withAccountMutationLock(() =>
+ 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;
+ }
+ },
+
+ 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 {
@@ -3915,8 +4647,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(
@@ -3960,7 +4699,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
@@ -4077,6 +4818,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/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/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();
diff --git a/src/helpers/accountGroups.ts b/src/helpers/accountGroups.ts
new file mode 100644
index 000000000..1d63ea4ba
--- /dev/null
+++ b/src/helpers/accountGroups.ts
@@ -0,0 +1,87 @@
+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 {
+ /** Stable across renders and sheet reopenings; keys collapsed state. */
+ 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/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;
+};
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/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/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));
}
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 8eae61f21..318cb5254 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,30 @@ 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
+ * 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/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 };
+};
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..0b0214e36
--- /dev/null
+++ b/src/hooks/useHasRecoveryPhrase.ts
@@ -0,0 +1,42 @@
+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,
+ );
+ // 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 () => {
+ isActive = false;
+ };
+ }, []),
+ );
+
+ return hasPhrase;
+};
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index b127bb0aa..fdd4afccf 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": {
@@ -87,7 +82,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": {
@@ -216,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": {
@@ -268,8 +266,15 @@
},
"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",
+ "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",
@@ -479,12 +484,14 @@
"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",
"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",
@@ -513,7 +520,11 @@
"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.",
+ "failedToImportRecoveryPhrase": "Failed to import the recovery phrase. Please try again."
},
"account": "Account {{number}}",
"faceId": {
@@ -594,7 +605,6 @@
"alreadyInWallet": "This collectible is already in your wallet",
"showHidden": "Show hidden"
},
-
"collectibles": {
"errors": {
"collectionNotFound": "Collection not found for the given address.",
@@ -903,20 +913,41 @@
"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",
+ "importRecoveryPhrase": "Import Recovery Phrase",
+ "importRecoveryPhraseDescription": "Import wallets using a 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",
"verifyPasswordTemplateDescription": "Enter your account password to verify your account.",
"actions": {
- "createNewWallet": "Create New Wallet"
+ "createNewWallet": "Create New Wallet",
+ "importRecoveryPhrase": "Import"
}
},
"logout": "Log out",
@@ -1389,5 +1420,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..19371e4bd 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": {
@@ -87,7 +82,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": {
@@ -182,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": {
@@ -234,8 +232,15 @@
},
"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",
+ "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",
@@ -445,12 +450,14 @@
"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",
"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",
@@ -479,7 +486,11 @@
"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.",
+ "failedToImportRecoveryPhrase": "Falha ao importar a frase de recuperação. Por favor, tente novamente."
},
"account": "Conta {{number}}",
"faceId": {
@@ -560,7 +571,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.",
@@ -869,20 +879,41 @@
"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",
+ "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."
}
},
+ "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",
"verifyPasswordTemplateDescription": "Digite sua senha para verificar sua conta.",
"actions": {
- "createNewWallet": "Criar nova carteira"
+ "createNewWallet": "Criar nova carteira",
+ "importRecoveryPhrase": "Importar"
}
},
"logout": "Sair",
@@ -1389,5 +1420,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/ManageWalletsNavigator.tsx b/src/navigators/ManageWalletsNavigator.tsx
index 65f3238c3..a0ddd4631 100644
--- a/src/navigators/ManageWalletsNavigator.tsx
+++ b/src/navigators/ManageWalletsNavigator.tsx
@@ -3,6 +3,9 @@ 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 ImportRecoveryPhraseScreen from "components/screens/ImportRecoveryPhraseScreen";
import ImportSecretKeyScreen from "components/screens/ImportSecretKeyScreen";
import VerifyPasswordScreen from "components/screens/VerifyPasswordScreen";
import {
@@ -33,10 +36,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"
+ />
+ ),
+ })}
+ />
{
component={ImportSecretKeyScreen}
options={resetNestedNavigationOptions(t("importSecretKeyScreen.title"))}
/>
+
);
};
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"))}
/>
+
+
+
{
// 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,
});
};
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);
};
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: