diff --git a/__tests__/components/mnemonic-word-input.spec.tsx b/__tests__/components/mnemonic-word-input.spec.tsx index a1adc22456..d67b97f2a1 100644 --- a/__tests__/components/mnemonic-word-input.spec.tsx +++ b/__tests__/components/mnemonic-word-input.spec.tsx @@ -1,7 +1,11 @@ -import React from "react" +import React, { createRef } from "react" +import { TextInput } from "react-native" import { render, fireEvent } from "@testing-library/react-native" -import { MnemonicWordInput } from "@app/components/mnemonic-word-input" +import { + MnemonicWordInput, + MnemonicWordInputHandle, +} from "@app/components/mnemonic-word-input" jest.mock("@rn-vui/themed", () => { const colors = { @@ -81,4 +85,16 @@ describe("MnemonicWordInput", () => { expect(onFocus).toHaveBeenCalled() }) + + it("forwards focus through ref to underlying TextInput", () => { + const ref = createRef() + const focusSpy = jest.spyOn(TextInput.prototype, "focus") + + render() + + ref.current?.focus() + + expect(focusSpy).toHaveBeenCalled() + focusSpy.mockRestore() + }) }) diff --git a/__tests__/components/password-input.spec.tsx b/__tests__/components/password-input.spec.tsx index 2c0f39194a..e32a9161e1 100644 --- a/__tests__/components/password-input.spec.tsx +++ b/__tests__/components/password-input.spec.tsx @@ -58,4 +58,16 @@ describe("PasswordInput", () => { expect(queryByText("Too short")).toBeNull() }) + + it("calls onBlur when the input loses focus", () => { + const onBlur = jest.fn() + const { getByPlaceholderText } = render( + + + , + ) + + fireEvent(getByPlaceholderText("Enter password"), "blur") + expect(onBlur).toHaveBeenCalledTimes(1) + }) }) diff --git a/__tests__/hooks/use-backup-nudge-state.spec.ts b/__tests__/hooks/use-backup-nudge-state.spec.ts index a7d697ac6c..9ac1738565 100644 --- a/__tests__/hooks/use-backup-nudge-state.spec.ts +++ b/__tests__/hooks/use-backup-nudge-state.spec.ts @@ -21,6 +21,18 @@ jest.mock("@app/config/feature-flags-context", () => ({ useRemoteConfig: () => mockRemoteConfig(), })) +const SATS_PER_USD_CENT = 10 + +jest.mock("@app/components/balance-header/use-total-balance", () => ({ + useTotalBalance: (wallets: Array<{ walletCurrency: string; balance: number }>) => ({ + satsBalance: wallets.reduce((sum, w) => { + if (w.walletCurrency === "BTC") return sum + w.balance + if (w.walletCurrency === "USD") return sum + w.balance * 10 + return sum + }, 0), + }), +})) + jest.mock("@react-native-async-storage/async-storage", () => ({ getItem: (...args: string[]) => mockGetItem(...args), setItem: (...args: string[]) => mockSetItem(...args), @@ -30,11 +42,11 @@ const defaultBackupState = { backupState: { status: "none", method: null } } const completedBackupState = { backupState: { status: "completed", method: "manual" } } const selfCustodialWallet = { accountType: "self-custodial", - wallets: [{ walletCurrency: "BTC", balance: { amount: 3000 } }], + wallets: [{ id: "btc-1", walletCurrency: "BTC", balance: { amount: 3000 } }], } const custodialWallet = { accountType: "custodial", - wallets: [{ walletCurrency: "BTC", balance: { amount: 50000 } }], + wallets: [{ id: "btc-1", walletCurrency: "BTC", balance: { amount: 50000 } }], } const defaultConfig = { backupNudgeBannerThreshold: 2100, @@ -51,14 +63,21 @@ describe("useBackupNudgeState", () => { mockSetItem.mockResolvedValue(undefined) }) - it("returns false for all flags before loaded resolves", () => { + it("hides banner and modal while dismissal-load is pending", () => { mockGetItem.mockReturnValue(new Promise(() => {})) const { result } = renderHook(() => useBackupNudgeState()) expect(result.current.shouldShowBanner).toBe(false) expect(result.current.shouldShowModal).toBe(false) - expect(result.current.shouldShowSettingsBanner).toBe(false) + }) + + it("shows settings banner without waiting for dismissal-load", () => { + mockGetItem.mockReturnValue(new Promise(() => {})) + + const { result } = renderHook(() => useBackupNudgeState()) + + expect(result.current.shouldShowSettingsBanner).toBe(true) }) it("shows banner when balance >= banner threshold and not backed up", async () => { @@ -73,7 +92,7 @@ describe("useBackupNudgeState", () => { it("shows modal when balance >= modal threshold", async () => { mockActiveWallet.mockReturnValue({ accountType: "self-custodial", - wallets: [{ walletCurrency: "BTC", balance: { amount: 22000 } }], + wallets: [{ id: "btc-1", walletCurrency: "BTC", balance: { amount: 22000 } }], }) const { result } = renderHook(() => useBackupNudgeState()) @@ -147,4 +166,48 @@ describe("useBackupNudgeState", () => { expect(result.current.shouldShowBanner).toBe(true) }) + + it("triggers banner when USD weight pushes combined balance over the threshold", async () => { + const btcAmount = 1_000 + const usdCentsAmount = 200 + const combinedSats = btcAmount + usdCentsAmount * SATS_PER_USD_CENT + expect(btcAmount).toBeLessThan(defaultConfig.backupNudgeBannerThreshold) + expect(combinedSats).toBeGreaterThanOrEqual(defaultConfig.backupNudgeBannerThreshold) + + mockActiveWallet.mockReturnValue({ + accountType: "self-custodial", + wallets: [ + { id: "btc-1", walletCurrency: "BTC", balance: { amount: btcAmount } }, + { id: "usd-1", walletCurrency: "USD", balance: { amount: usdCentsAmount } }, + ], + }) + + const { result } = renderHook(() => useBackupNudgeState()) + + await act(async () => {}) + + expect(result.current.shouldShowBanner).toBe(true) + }) + + it("triggers modal when USD weight pushes combined balance over the modal threshold", async () => { + const btcAmount = 1_000 + const usdCentsAmount = 2_200 + const combinedSats = btcAmount + usdCentsAmount * SATS_PER_USD_CENT + expect(btcAmount).toBeLessThan(defaultConfig.backupNudgeModalThreshold) + expect(combinedSats).toBeGreaterThanOrEqual(defaultConfig.backupNudgeModalThreshold) + + mockActiveWallet.mockReturnValue({ + accountType: "self-custodial", + wallets: [ + { id: "btc-1", walletCurrency: "BTC", balance: { amount: btcAmount } }, + { id: "usd-1", walletCurrency: "USD", balance: { amount: usdCentsAmount } }, + ], + }) + + const { result } = renderHook(() => useBackupNudgeState()) + + await act(async () => {}) + + expect(result.current.shouldShowModal).toBe(true) + }) }) diff --git a/__tests__/hooks/use-bip39-input.spec.ts b/__tests__/hooks/use-bip39-input.spec.ts index 359682f5f6..d038aafad3 100644 --- a/__tests__/hooks/use-bip39-input.spec.ts +++ b/__tests__/hooks/use-bip39-input.spec.ts @@ -3,11 +3,29 @@ import { renderHook, act } from "@testing-library/react-native" import { useBip39Input } from "@app/hooks/use-bip39-input" jest.mock("@app/utils/bip39-wordlist", () => ({ - BIP39_WORDLIST_EN: ["abandon", "ability", "able", "about", "above", "absent"], - getBip39Suggestions: (prefix: string) => - ["abandon", "ability", "able", "about", "above", "absent"].filter((w) => - w.startsWith(prefix), - ), + BIP39_WORDLIST_EN: [ + "abandon", + "ability", + "able", + "about", + "above", + "absent", + "run", + "runway", + ], + getBip39Suggestions: (prefix: string, options?: { maxResults?: number }) => { + const all = [ + "abandon", + "ability", + "able", + "about", + "above", + "absent", + "run", + "runway", + ].filter((w) => w.startsWith(prefix)) + return options?.maxResults ? all.slice(0, options.maxResults) : all + }, splitWords: (text: string) => text.trim().toLowerCase().split(/\s+/), })) @@ -177,4 +195,62 @@ describe("useBip39Input", () => { expect(result.current.words[5]).toBe("absent") expect(result.current.activeIndex).toBe(5) }) + + it("requests focus on next index when input is a prefix-unique BIP39 word", () => { + const { result } = renderHook(() => useBip39Input({ wordCount: 12 })) + + act(() => { + result.current.updateWord(0, "abandon") + }) + + expect(result.current.focusRequest).toBe(1) + }) + + it("does not advance when input is BIP39 word but prefix has multiple matches", () => { + const { result } = renderHook(() => useBip39Input({ wordCount: 12 })) + + act(() => { + result.current.updateWord(0, "run") + }) + + expect(result.current.focusRequest).toBeNull() + }) + + it("does not advance when input is not a BIP39 word", () => { + const { result } = renderHook(() => useBip39Input({ wordCount: 12 })) + + act(() => { + result.current.updateWord(0, "ru") + }) + + expect(result.current.focusRequest).toBeNull() + }) + + it("does not advance past last index in step", () => { + const { result } = renderHook(() => + useBip39Input({ wordCount: 12, wordsPerStep: 6, step: 1 }), + ) + + act(() => { + result.current.updateWord(5, "abandon") + }) + + expect(result.current.focusRequest).toBeNull() + }) + + it("clearFocusRequest resets focusRequest", () => { + const { result } = renderHook(() => useBip39Input({ wordCount: 12 })) + + act(() => { + result.current.updateWord(0, "abandon") + }) + + expect(result.current.focusRequest).toBe(1) + + act(() => { + result.current.clearFocusRequest() + }) + + expect(result.current.focusRequest).toBeNull() + }) }) diff --git a/__tests__/i18n/locale-parity.test.ts b/__tests__/i18n/locale-parity.test.ts new file mode 100644 index 0000000000..0d0ee4c93c --- /dev/null +++ b/__tests__/i18n/locale-parity.test.ts @@ -0,0 +1,64 @@ +import { loadLocale } from "@app/i18n/i18n-util.sync" +import { i18nObject } from "@app/i18n/i18n-util" +import type { Locales } from "@app/i18n/i18n-types" + +const LOCALES: ReadonlyArray = [ + "af", + "ar", + "ca", + "cs", + "da", + "de", + "el", + "en", + "es", + "fr", + "hr", + "hu", + "hy", + "it", + "ja", + "lg", + "ms", + "nl", + "pt", + "qu", + "ro", + "sk", + "sr", + "sw", + "th", + "tr", + "vi", +] + +describe("locale parity for backup/restore i18n keys", () => { + LOCALES.forEach((locale) => { + describe(`locale: ${locale}`, () => { + beforeAll(() => { + loadLocale(locale) + }) + + it("exposes a non-empty SettingsScreen.recoveryMethod string", () => { + const LL = i18nObject(locale) + const value = LL.SettingsScreen.recoveryMethod() + expect(typeof value).toBe("string") + expect(value.length).toBeGreaterThan(0) + }) + + it("exposes a non-empty RestoreScreen.invalidMnemonic string", () => { + const LL = i18nObject(locale) + const value = LL.RestoreScreen.invalidMnemonic() + expect(typeof value).toBe("string") + expect(value.length).toBeGreaterThan(0) + }) + + it("exposes a non-empty BackupScreen.ManualBackup.Phrase.headerTitle string", () => { + const LL = i18nObject(locale) + const value = LL.BackupScreen.ManualBackup.Phrase.headerTitle() + expect(typeof value).toBe("string") + expect(value.length).toBeGreaterThan(0) + }) + }) + }) +}) diff --git a/__tests__/screens/account-type-selection/account-type-selection.spec.tsx b/__tests__/screens/account-type-selection/account-type-selection.spec.tsx index 3848f95b26..216e139b1e 100644 --- a/__tests__/screens/account-type-selection/account-type-selection.spec.tsx +++ b/__tests__/screens/account-type-selection/account-type-selection.spec.tsx @@ -36,21 +36,28 @@ jest.mock("@app/i18n/i18n-react", () => ({ }), })) +const mockCardDefaultBg = "#1d1d1d" +const mockCardSelectedBg = "#2B2B2B" +const mockPrimary = "#fc5805" + jest.mock("@rn-vui/themed", () => ({ makeStyles: (fn: (args: { colors: Record }) => Record) => () => fn({ colors: { - primary: "#000", + primary: "#fc5805", grey2: "#949494", grey3: "#999", - grey5: "#eee", + grey5: "#1d1d1d", + grey6: "#2B2B2B", black: "#000", }, }), Text: ({ children, ...props }: { children: React.ReactNode }) => React.createElement("Text", props, children), - useTheme: () => ({ theme: { colors: { primary: "#000", grey5: "#eee" } } }), + useTheme: () => ({ + theme: { colors: { primary: "#fc5805", grey5: "#1d1d1d", grey6: "#2B2B2B" } }, + }), })) jest.mock("@app/components/atomic/galoy-icon", () => ({ @@ -166,4 +173,33 @@ describe("AccountTypeSelectionScreen", () => { expect(mockNavigate).not.toHaveBeenCalled() }) + + it("uses grey5 as default card background and grey6 when selected", () => { + const { getByTestId } = render() + + const custodialCard = getByTestId("custodial-option") + const selfCustodialCard = getByTestId("self-custodial-option") + + expect(custodialCard.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ backgroundColor: mockCardDefaultBg }), + ]), + ) + expect(selfCustodialCard.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ backgroundColor: mockCardDefaultBg }), + ]), + ) + + fireEvent.press(custodialCard) + + expect(custodialCard.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + backgroundColor: mockCardSelectedBg, + borderColor: mockPrimary, + }), + ]), + ) + }) }) diff --git a/__tests__/screens/home.spec.tsx b/__tests__/screens/home.spec.tsx index 9c87566fe8..0d9300a6bb 100644 --- a/__tests__/screens/home.spec.tsx +++ b/__tests__/screens/home.spec.tsx @@ -23,15 +23,24 @@ jest.mock("@react-native-async-storage/async-storage", () => ({ }, })) +const mockBackupNudgeState = { + shouldShowBanner: false, + shouldShowModal: false, + shouldShowSettingsBanner: false, + dismissBanner: jest.fn(), +} jest.mock("@app/hooks/use-backup-nudge-state", () => ({ - useBackupNudgeState: () => ({ - shouldShowBanner: false, - shouldShowModal: false, - shouldShowSettingsBanner: false, - dismissBanner: jest.fn(), - }), + useBackupNudgeState: () => mockBackupNudgeState, +})) + +type NudgeModalProps = { isVisible: boolean; onClose: () => void } +const mockBackupNudgeModal = jest.fn(() => null) +jest.mock("@app/components/backup-nudge-modal", () => ({ + BackupNudgeModal: (props: NudgeModalProps) => mockBackupNudgeModal(props), })) +let mockIsFocused = true + jest.mock("@app/screens/spark-onboarding/trust-model-screen", () => ({ SparkTrustModelScreen: () => null, useTrustModelSeen: () => ({ seen: true, markAsSeen: jest.fn() }), @@ -164,6 +173,7 @@ jest.mock("@react-navigation/native", () => { ...actual.useNavigation?.(), navigate: mockNavigate, }), + useIsFocused: () => mockIsFocused, } }) @@ -590,4 +600,79 @@ describe("HomeScreen", () => { expect(mockToggleBalanceMode).toHaveBeenCalledTimes(1) }) }) + + describe("BackupNudgeModal focus gating", () => { + const lastIsVisible = (): boolean => { + const calls = mockBackupNudgeModal.mock.calls + expect(calls.length).toBeGreaterThan(0) + return calls[calls.length - 1][0].isVisible + } + + beforeEach(() => { + mockBackupNudgeModal.mockClear() + mockBackupNudgeState.shouldShowModal = false + mockIsFocused = true + }) + + afterEach(() => { + mockBackupNudgeState.shouldShowModal = false + mockIsFocused = true + }) + + it("passes isVisible=true only when both isFocused and shouldShowModal are true", async () => { + mockBackupNudgeState.shouldShowModal = true + mockIsFocused = true + + render( + + + , + ) + await act(async () => {}) + + expect(lastIsVisible()).toBe(true) + }) + + it("passes isVisible=false when the home tab is not focused", async () => { + mockBackupNudgeState.shouldShowModal = true + mockIsFocused = false + + render( + + + , + ) + await act(async () => {}) + + expect(lastIsVisible()).toBe(false) + }) + + it("passes isVisible=false when the nudge state says it should not be shown", async () => { + mockBackupNudgeState.shouldShowModal = false + mockIsFocused = true + + render( + + + , + ) + await act(async () => {}) + + expect(lastIsVisible()).toBe(false) + }) + + it("passes isVisible=false when neither condition is met", async () => { + mockBackupNudgeState.shouldShowModal = false + mockIsFocused = false + + render( + + + , + ) + await act(async () => {}) + + expect(lastIsVisible()).toBe(false) + }) + }) }) diff --git a/__tests__/screens/settings-screen/settings-screen.spec.tsx b/__tests__/screens/settings-screen/settings-screen.spec.tsx index 52cc4b0c59..75194366c2 100644 --- a/__tests__/screens/settings-screen/settings-screen.spec.tsx +++ b/__tests__/screens/settings-screen/settings-screen.spec.tsx @@ -7,6 +7,12 @@ jest.mock("@app/hooks/use-backup-nudge-state", () => ({ }), })) +const mockUseIsAuthed = jest.fn(() => true) +jest.mock("@app/graphql/is-authed-context", () => ({ + ...jest.requireActual("@app/graphql/is-authed-context"), + useIsAuthed: () => mockUseIsAuthed(), +})) + import React from "react" import { TouchableOpacity, View } from "react-native" import { act, fireEvent, render, screen, within } from "@testing-library/react-native" @@ -537,4 +543,67 @@ describe("Settings Screen", () => { expect(screen.getByText("Move to non-custodial")).toBeTruthy() }) + + it("does not render a standalone Recovery method group (Critical #7)", async () => { + render( + + + , + ) + + await act( + () => + new Promise((resolve) => { + setTimeout(resolve, 10) + }), + ) + + expect(screen.queryByTestId("Recovery method-group")).toBeNull() + }) + + it("skips the unread-notifications query when not authenticated", async () => { + mockUseIsAuthed.mockReturnValue(false) + const generated = jest.requireMock("@app/graphql/generated") + generated.useUnacknowledgedNotificationCountQuery.mockClear() + + render( + + + , + ) + + await act( + () => + new Promise((resolve) => { + setTimeout(resolve, 10) + }), + ) + + expect(generated.useUnacknowledgedNotificationCountQuery).toHaveBeenCalledWith( + expect.objectContaining({ skip: true }), + ) + }) + + it("runs the unread-notifications query when authenticated", async () => { + mockUseIsAuthed.mockReturnValue(true) + const generated = jest.requireMock("@app/graphql/generated") + generated.useUnacknowledgedNotificationCountQuery.mockClear() + + render( + + + , + ) + + await act( + () => + new Promise((resolve) => { + setTimeout(resolve, 10) + }), + ) + + expect(generated.useUnacknowledgedNotificationCountQuery).toHaveBeenCalledWith( + expect.objectContaining({ skip: false }), + ) + }) }) diff --git a/__tests__/screens/settings-screen/skip-queries-when-unauthed.spec.tsx b/__tests__/screens/settings-screen/skip-queries-when-unauthed.spec.tsx new file mode 100644 index 0000000000..387ad9feb5 --- /dev/null +++ b/__tests__/screens/settings-screen/skip-queries-when-unauthed.spec.tsx @@ -0,0 +1,189 @@ +import React from "react" +import { it } from "@jest/globals" +import { render, renderHook } from "@testing-library/react-native" +import { ThemeProvider } from "@rn-vui/themed" + +import { IsAuthedContextProvider } from "@app/graphql/is-authed-context" +import theme from "@app/rne-theme/theme" + +const mockUseSettingsScreenQuery = jest.fn() +const mockUseUnacknowledgedNotificationCountQuery = jest.fn() + +jest.mock("@app/graphql/generated", () => ({ + useSettingsScreenQuery: (opts: unknown) => mockUseSettingsScreenQuery(opts), + useUnacknowledgedNotificationCountQuery: (opts: unknown) => + mockUseUnacknowledgedNotificationCountQuery(opts), + useExportCsvSettingLazyQuery: () => [jest.fn(), { loading: false }], +})) + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useNavigation: () => ({ navigate: jest.fn() }), +})) + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + common: { + language: () => "Language", + csvExport: () => "CSV", + blinkUser: () => "Blink User", + bitcoin: () => "Bitcoin", + dollar: () => "USD", + }, + SettingsScreen: { + setByOs: () => "Default", + setYourLightningAddress: () => "Set address", + pos: () => "POS", + staticQr: () => "Static QR", + logInOrCreateAccount: () => "Login", + }, + DefaultWalletScreen: { title: () => "Default wallet" }, + GaloyAddressScreen: { copiedLightningAddressToClipboard: () => "Copied" }, + }, + }), +})) + +jest.mock("@app/hooks", () => ({ + useAppConfig: () => ({ + appConfig: { + galoyInstance: { + lnAddressHostname: "blink.sv", + posUrl: "https://pos.test", + }, + }, + }), + useClipboard: () => ({ copyToClipboard: jest.fn() }), +})) + +jest.mock("@rn-vui/themed", () => ({ + ...jest.requireActual("@rn-vui/themed"), + useTheme: () => ({ + theme: { + colors: { + primary: "#000", + error: "#f00", + grey4: "#ddd", + grey5: "#eee", + black: "#000", + }, + }, + }), +})) + +jest.mock("@app/graphql/level-context", () => ({ + AccountLevel: { NonAuth: "NonAuth", Zero: "Zero", One: "One" }, + useLevel: () => ({ currentLevel: "NonAuth" }), +})) + +jest.mock("@app/components/set-lightning-address-modal", () => ({ + SetLightningAddressModal: () => null, +})) + +jest.mock("@react-native-firebase/crashlytics", () => () => ({ + recordError: jest.fn(), +})) + +jest.mock("react-native-share", () => ({ + __esModule: true, + default: { open: jest.fn() }, +})) + +import { AccountBanner } from "@app/screens/settings-screen/account/banner" +import { useLoginMethods } from "@app/screens/settings-screen/account/login-methods-hook" +import { DefaultWallet } from "@app/screens/settings-screen/settings/account-default-wallet" +import { AccountLNAddress } from "@app/screens/settings-screen/settings/account-ln-address" +import { AccountPOS } from "@app/screens/settings-screen/settings/account-pos" +import { AccountStaticQR } from "@app/screens/settings-screen/settings/account-static-qr" +import { ExportCsvSetting } from "@app/screens/settings-screen/settings/advanced-export-csv" +import { LanguageSetting } from "@app/screens/settings-screen/settings/preferences-language" + +const renderWithAuth = (component: React.ReactElement, isAuthed: boolean) => + render( + + {component} + , + ) + +describe("settings skips graphql queries when unauthenticated", () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseSettingsScreenQuery.mockReturnValue({ data: undefined, loading: false }) + mockUseUnacknowledgedNotificationCountQuery.mockReturnValue({ data: undefined }) + }) + + describe("useSettingsScreenQuery consumers without fetchPolicy", () => { + const consumers = [ + { name: "LanguageSetting", make: () => }, + { name: "DefaultWallet", make: () => }, + { name: "AccountPOS", make: () => }, + { name: "AccountStaticQR", make: () => }, + { name: "AccountLNAddress", make: () => }, + { name: "ExportCsvSetting", make: () => }, + ] + + it.each(consumers)("$name passes skip: true when unauthed", ({ make }) => { + renderWithAuth(make(), false) + + expect(mockUseSettingsScreenQuery).toHaveBeenCalledWith( + expect.objectContaining({ skip: true }), + ) + }) + + it.each(consumers)("$name passes skip: false when authed", ({ make }) => { + renderWithAuth(make(), true) + + expect(mockUseSettingsScreenQuery).toHaveBeenCalledWith( + expect.objectContaining({ skip: false }), + ) + }) + }) + + describe("AccountBanner", () => { + it("applies skip: !isAuthed while preserving fetchPolicy: cache-first", () => { + renderWithAuth(, false) + + expect(mockUseSettingsScreenQuery).toHaveBeenCalledWith({ + skip: true, + fetchPolicy: "cache-first", + }) + }) + + it("does not skip when authed and keeps fetchPolicy: cache-first", () => { + renderWithAuth(, true) + + expect(mockUseSettingsScreenQuery).toHaveBeenCalledWith({ + skip: false, + fetchPolicy: "cache-first", + }) + }) + }) + + describe("useLoginMethods", () => { + const wrap = (isAuthed: boolean) => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + Wrapper.displayName = "AuthWrapper" + return Wrapper + } + + it("applies skip: !isAuthed while preserving fetchPolicy: cache-and-network", () => { + renderHook(() => useLoginMethods(), { wrapper: wrap(false) }) + + expect(mockUseSettingsScreenQuery).toHaveBeenCalledWith({ + skip: true, + fetchPolicy: "cache-and-network", + }) + }) + + it("does not skip when authed and keeps fetchPolicy: cache-and-network", () => { + renderHook(() => useLoginMethods(), { wrapper: wrap(true) }) + + expect(mockUseSettingsScreenQuery).toHaveBeenCalledWith({ + skip: false, + fetchPolicy: "cache-and-network", + }) + }) + }) +}) diff --git a/__tests__/screens/spark-onboarding/backup-confirm-screen.spec.tsx b/__tests__/screens/spark-onboarding/backup-confirm-screen.spec.tsx index 919fb45d63..5c9f6e7b2a 100644 --- a/__tests__/screens/spark-onboarding/backup-confirm-screen.spec.tsx +++ b/__tests__/screens/spark-onboarding/backup-confirm-screen.spec.tsx @@ -11,8 +11,14 @@ jest.mock("react-native-inappbrowser-reborn", () => ({ default: { open: jest.fn(() => Promise.resolve()) }, })) +const mockCheckpoint = jest.fn() +const mockCheckpointLoading = jest.fn() jest.mock("@app/screens/account-migration/hooks", () => ({ - useMigrationCheckpoint: () => ({ saveCheckpoint: jest.fn() }), + useMigrationCheckpoint: () => ({ + saveCheckpoint: jest.fn(), + checkpoint: mockCheckpoint(), + loading: mockCheckpointLoading(), + }), MigrationCheckpoint: { BackupMethod: "backupMethod", CloudBackup: "cloudBackup", @@ -20,6 +26,23 @@ jest.mock("@app/screens/account-migration/hooks", () => ({ }, })) +const mockBackupStateValue = jest.fn< + { + backupState: { status: string; method: string | null } + setBackupCompleted: jest.Mock + }, + [] +>() +jest.mock("@app/self-custodial/providers/backup-state-provider", () => ({ + BackupStatus: { None: "none", Completed: "completed" }, + useBackupState: () => mockBackupStateValue(), +})) + +const mockActiveWalletValue = jest.fn() +jest.mock("@app/hooks/use-active-wallet", () => ({ + useActiveWallet: () => mockActiveWalletValue(), +})) + jest.mock("@app/graphql/generated", () => ({ ...jest.requireActual("@app/graphql/generated"), useHomeAuthedQuery: () => ({ @@ -51,10 +74,21 @@ jest.mock("@react-navigation/native", () => ({ loadLocale("en") const LL = i18nObject("en") +const mockSetBackupCompleted = jest.fn() + describe("SparkBackupConfirmScreen", () => { beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers() + mockCheckpoint.mockReturnValue(null) + mockCheckpointLoading.mockReturnValue(false) + mockBackupStateValue.mockReturnValue({ + backupState: { status: "none", method: null }, + setBackupCompleted: mockSetBackupCompleted, + }) + mockActiveWalletValue.mockReturnValue({ + wallets: [{ id: "btc-1", balance: { amount: 1000 }, walletCurrency: "BTC" }], + }) }) afterEach(() => { @@ -137,4 +171,131 @@ describe("SparkBackupConfirmScreen", () => { expect(getByText("1.")).toBeTruthy() }) + + const fillAllChallenges = (getByPlaceholderText: (p: string) => unknown) => { + fireEvent.changeText( + getByPlaceholderText( + `${LL.BackupScreen.ManualBackup.Confirm.enterWord()} 1`, + ) as never, + "youth", + ) + fireEvent.changeText( + getByPlaceholderText( + `${LL.BackupScreen.ManualBackup.Confirm.enterWord()} 5`, + ) as never, + "bundle", + ) + fireEvent.changeText( + getByPlaceholderText( + `${LL.BackupScreen.ManualBackup.Confirm.enterWord()} 9`, + ) as never, + "harvest", + ) + } + + it("routes to migration transferring screen when migrating with funds", () => { + mockCheckpoint.mockReturnValue("backupAlerts") + mockBackupStateValue.mockReturnValue({ + backupState: { status: "none", method: null }, + setBackupCompleted: mockSetBackupCompleted, + }) + + const { getByPlaceholderText } = render( + + + , + ) + + fillAllChallenges(getByPlaceholderText) + jest.advanceTimersByTime(500) + + expect(mockSetBackupCompleted).toHaveBeenCalledWith("manual") + expect(mockNavigate).toHaveBeenCalledWith("sparkMigrationTransferringFunds") + }) + + it("routes to backup success screen with reBackup=true when re-backing-up from settings", () => { + mockCheckpoint.mockReturnValue("backupAlerts") + mockBackupStateValue.mockReturnValue({ + backupState: { status: "completed", method: "manual" }, + setBackupCompleted: mockSetBackupCompleted, + }) + + const { getByPlaceholderText } = render( + + + , + ) + + fillAllChallenges(getByPlaceholderText) + jest.advanceTimersByTime(500) + + expect(mockNavigate).toHaveBeenCalledWith("sparkBackupSuccessScreen", { + reBackup: true, + }) + }) + + it("routes to backup success screen with reBackup=false during fresh manual backup without checkpoint", () => { + mockCheckpoint.mockReturnValue(null) + + const { getByPlaceholderText } = render( + + + , + ) + + fillAllChallenges(getByPlaceholderText) + jest.advanceTimersByTime(500) + + expect(mockNavigate).toHaveBeenCalledWith("sparkBackupSuccessScreen", { + reBackup: false, + }) + }) + + it("does not route to migration when migrating but no funds", () => { + mockCheckpoint.mockReturnValue("backupAlerts") + mockActiveWalletValue.mockReturnValue({ + wallets: [{ id: "btc-1", balance: { amount: 0 }, walletCurrency: "BTC" }], + }) + + const { getByPlaceholderText } = render( + + + , + ) + + fillAllChallenges(getByPlaceholderText) + jest.advanceTimersByTime(500) + + expect(mockNavigate).not.toHaveBeenCalledWith("sparkMigrationTransferringFunds") + expect(mockNavigate).toHaveBeenCalledWith("sparkBackupSuccessScreen", { + reBackup: false, + }) + }) + + it("does not auto-navigate while the migration checkpoint is still loading (Critical #1)", () => { + mockCheckpoint.mockReturnValue(null) + mockCheckpointLoading.mockReturnValue(true) + + const { getByPlaceholderText, rerender } = render( + + + , + ) + + fillAllChallenges(getByPlaceholderText) + jest.advanceTimersByTime(500) + + expect(mockNavigate).not.toHaveBeenCalled() + + mockCheckpoint.mockReturnValue("backupAlerts") + mockCheckpointLoading.mockReturnValue(false) + rerender( + + + , + ) + jest.advanceTimersByTime(500) + + expect(mockNavigate).toHaveBeenCalledWith("sparkMigrationTransferringFunds") + }) }) diff --git a/__tests__/screens/spark-onboarding/backup-method-screen.spec.tsx b/__tests__/screens/spark-onboarding/backup-method-screen.spec.tsx index c4f7f1bdce..8c322bb744 100644 --- a/__tests__/screens/spark-onboarding/backup-method-screen.spec.tsx +++ b/__tests__/screens/spark-onboarding/backup-method-screen.spec.tsx @@ -4,7 +4,9 @@ import { Pressable, Text } from "react-native" import { loadLocale } from "@app/i18n/i18n-util.sync" import { i18nObject } from "@app/i18n/i18n-util" +import { IconHero } from "@app/components/icon-hero" import { SparkBackupMethodScreen } from "@app/screens/spark-onboarding/backup-method-screen" +import theme from "@app/rne-theme/theme" import { ContextForScreen } from "../helper" const mockHandleKeychainBackup = jest.fn() @@ -62,12 +64,12 @@ jest.mock("@app/components/atomic/galoy-secondary-button", () => ({ jest.mock("@app/components/icon-hero", () => { return { - IconHero: ({ title, subtitle }: { title: string; subtitle: string }) => ( + IconHero: jest.fn(({ title, subtitle }: { title: string; subtitle: string }) => ( <> {title} {subtitle} - ), + )), } }) @@ -171,4 +173,18 @@ describe("SparkBackupMethodScreen", () => { expect(mockSaveCheckpoint).toHaveBeenCalledWith("backupMethod") }) + + it("renders the hero icon with the success color", () => { + render( + + + , + ) + + const iconHeroMock = IconHero as unknown as jest.Mock + const props = iconHeroMock.mock.calls[0][0] + + expect(props.iconColor).toBe(theme.lightColors?.success) + expect(props.icon).toBe("cloud") + }) }) diff --git a/__tests__/screens/spark-onboarding/backup-success-screen.spec.tsx b/__tests__/screens/spark-onboarding/backup-success-screen.spec.tsx index 6ee52e2304..b12e5d0412 100644 --- a/__tests__/screens/spark-onboarding/backup-success-screen.spec.tsx +++ b/__tests__/screens/spark-onboarding/backup-success-screen.spec.tsx @@ -39,9 +39,11 @@ jest.mock("@app/screens/account-migration/hooks", () => ({ })) const mockDispatch = jest.fn() +const mockRouteParams = jest.fn<{ reBackup?: boolean } | undefined, []>() jest.mock("@react-navigation/native", () => ({ ...jest.requireActual("@react-navigation/native"), useNavigation: () => ({ dispatch: mockDispatch }), + useRoute: () => ({ params: mockRouteParams() }), CommonActions: { reset: (config: { index: number; routes: Array<{ name: string }> }) => ({ type: "RESET", @@ -56,6 +58,7 @@ const LL = i18nObject("en") describe("SparkBackupSuccessScreen", () => { beforeEach(() => { jest.clearAllMocks() + mockRouteParams.mockReturnValue(undefined) }) it("renders success message", () => { @@ -68,6 +71,19 @@ describe("SparkBackupSuccessScreen", () => { expect(getByText(LL.BackupScreen.ManualBackup.Success.title())).toBeTruthy() }) + it("renders generic success copy when reBackup param is true", () => { + mockRouteParams.mockReturnValue({ reBackup: true }) + + const { getByText, queryByText } = render( + + + , + ) + + expect(getByText(LL.common.success())).toBeTruthy() + expect(queryByText(LL.BackupScreen.ManualBackup.Success.title())).toBeNull() + }) + it("renders without crashing", () => { const { toJSON } = render( diff --git a/__tests__/screens/spark-onboarding/cloud-backup-screen.spec.tsx b/__tests__/screens/spark-onboarding/cloud-backup-screen.spec.tsx index b4f7177459..739713abee 100644 --- a/__tests__/screens/spark-onboarding/cloud-backup-screen.spec.tsx +++ b/__tests__/screens/spark-onboarding/cloud-backup-screen.spec.tsx @@ -3,7 +3,10 @@ import { render, fireEvent } from "@testing-library/react-native" import { loadLocale } from "@app/i18n/i18n-util.sync" import { i18nObject } from "@app/i18n/i18n-util" +import { IconHero } from "@app/components/icon-hero" +import { InfoBanner } from "@app/components/info-banner" import { SparkCloudBackupScreen } from "@app/screens/spark-onboarding/cloud-backup-screen" +import theme from "@app/rne-theme/theme" import { ContextForScreen } from "../helper" const mockHandleBackup = jest.fn() @@ -35,11 +38,25 @@ jest.mock("@app/screens/spark-onboarding/hooks", () => ({ jest.mock("@app/components/icon-hero", () => { const { Text } = jest.requireActual("react-native") return { - IconHero: ({ title, subtitle }: { title: string; subtitle: string }) => ( + IconHero: jest.fn(({ title, subtitle }: { title: string; subtitle: string }) => ( <> {title} {subtitle} + )), + } +}) + +jest.mock("@app/components/info-banner", () => { + const { Text } = jest.requireActual("react-native") + return { + InfoBanner: jest.fn( + ({ title, children }: { title?: string; children: React.ReactNode }) => ( + <> + {title ? {title} : null} + {children} + + ), ), } }) @@ -110,4 +127,35 @@ describe("SparkCloudBackupScreen", () => { fireEvent.press(getByText(LL.BackupScreen.CloudBackup.continueButton())) expect(mockHandleBackup).toHaveBeenCalled() }) + + it("renders the Important InfoBanner with warning icon color", () => { + mockIsEncrypted = true + + render( + + + , + ) + + const infoBannerMock = InfoBanner as unknown as jest.Mock + const props = infoBannerMock.mock.calls[0][0] + + expect(props.icon).toBe("warning") + expect(props.iconColor).toBe("warning") + expect(props.title).toBe(LL.BackupScreen.CloudBackup.importantTitle()) + }) + + it("renders the hero icon with the success color", () => { + render( + + + , + ) + + const iconHeroMock = IconHero as unknown as jest.Mock + const props = iconHeroMock.mock.calls[0][0] + + expect(props.iconColor).toBe(theme.lightColors?.success) + expect(props.icon).toBe("cloud") + }) }) diff --git a/__tests__/screens/spark-onboarding/hooks/use-backup-confirm.spec.ts b/__tests__/screens/spark-onboarding/hooks/use-backup-confirm.spec.ts index 103dee908f..1710fa7307 100644 --- a/__tests__/screens/spark-onboarding/hooks/use-backup-confirm.spec.ts +++ b/__tests__/screens/spark-onboarding/hooks/use-backup-confirm.spec.ts @@ -124,4 +124,84 @@ describe("useBackupConfirm", () => { expect(mockOnComplete).not.toHaveBeenCalled() }) + + it("requests focus on next index when correct word is typed", () => { + const { result } = renderHook(() => + useBackupConfirm({ challenges, onComplete: mockOnComplete }), + ) + + act(() => result.current.updateInput(0, "youth")) + + expect(result.current.focusRequest).toBe(1) + }) + + it("does not request focus past the last challenge", () => { + const { result } = renderHook(() => + useBackupConfirm({ challenges, onComplete: mockOnComplete }), + ) + + act(() => result.current.updateInput(2, "harvest")) + + expect(result.current.focusRequest).toBeNull() + }) + + it("does not request focus when typed word is wrong", () => { + const { result } = renderHook(() => + useBackupConfirm({ challenges, onComplete: mockOnComplete }), + ) + + act(() => result.current.updateInput(0, "young")) + + expect(result.current.focusRequest).toBeNull() + }) + + it("clearFocusRequest resets focusRequest", () => { + const { result } = renderHook(() => + useBackupConfirm({ challenges, onComplete: mockOnComplete }), + ) + + act(() => result.current.updateInput(0, "youth")) + expect(result.current.focusRequest).toBe(1) + + act(() => result.current.clearFocusRequest()) + + expect(result.current.focusRequest).toBeNull() + }) + + it("does not auto-complete while disabled, then fires once unlocked (Critical #1)", () => { + const { result, rerender } = renderHook( + ({ disabled }: { disabled: boolean }) => + useBackupConfirm({ challenges, onComplete: mockOnComplete, disabled }), + { initialProps: { disabled: true } }, + ) + + act(() => result.current.selectSuggestion(0, "youth")) + act(() => result.current.selectSuggestion(1, "bundle")) + act(() => result.current.selectSuggestion(2, "harvest")) + + act(() => jest.advanceTimersByTime(500)) + expect(mockOnComplete).not.toHaveBeenCalled() + + rerender({ disabled: false }) + act(() => jest.advanceTimersByTime(500)) + + expect(mockOnComplete).toHaveBeenCalledTimes(1) + }) + + it("re-arms the auto-advance timer after a de-correct then re-correct sequence (Critical #6)", () => { + const { result } = renderHook(() => + useBackupConfirm({ challenges, onComplete: mockOnComplete }), + ) + + act(() => result.current.selectSuggestion(0, "youth")) + act(() => result.current.selectSuggestion(1, "bundle")) + act(() => result.current.selectSuggestion(2, "harvest")) + + act(() => result.current.updateInput(2, "wrong")) + + act(() => result.current.selectSuggestion(2, "harvest")) + act(() => jest.advanceTimersByTime(500)) + + expect(mockOnComplete).toHaveBeenCalledTimes(1) + }) }) diff --git a/__tests__/screens/spark-onboarding/hooks/use-cloud-backup-form.spec.ts b/__tests__/screens/spark-onboarding/hooks/use-cloud-backup-form.spec.ts index 66863c51ab..480ddfdd36 100644 --- a/__tests__/screens/spark-onboarding/hooks/use-cloud-backup-form.spec.ts +++ b/__tests__/screens/spark-onboarding/hooks/use-cloud-backup-form.spec.ts @@ -72,27 +72,97 @@ describe("useCloudBackupForm", () => { expect(result.current.isValid).toBe(false) }) - it("shows password too short error", () => { + it("shows password too short error after the field is marked touched", () => { const { result } = renderHook(() => useCloudBackupForm()) act(() => result.current.toggleEncryption()) act(() => result.current.setPassword("short")) + act(() => result.current.markPasswordTouched()) expect(result.current.passwordError).toBe("Minimum 12 characters") expect(result.current.isValid).toBe(false) }) - it("shows password mismatch error", () => { + it("does not show password error while typing before the field is touched", () => { + const { result } = renderHook(() => useCloudBackupForm()) + + act(() => result.current.toggleEncryption()) + act(() => result.current.setPassword("short")) + + expect(result.current.passwordError).toBeUndefined() + expect(result.current.isValid).toBe(false) + }) + + it("resets touched flag when encryption is toggled off", () => { + const { result } = renderHook(() => useCloudBackupForm()) + + act(() => result.current.toggleEncryption()) + act(() => result.current.setPassword("short")) + act(() => result.current.markPasswordTouched()) + + expect(result.current.passwordError).toBe("Minimum 12 characters") + + act(() => result.current.toggleEncryption()) + act(() => result.current.toggleEncryption()) + act(() => result.current.setPassword("shorty")) + + expect(result.current.passwordError).toBeUndefined() + }) + + it("clears password touched flag when the field is emptied", () => { + const { result } = renderHook(() => useCloudBackupForm()) + + act(() => result.current.toggleEncryption()) + act(() => result.current.setPassword("short")) + act(() => result.current.markPasswordTouched()) + + expect(result.current.passwordError).toBe("Minimum 12 characters") + + act(() => result.current.setPassword("")) + act(() => result.current.setPassword("again")) + + expect(result.current.passwordError).toBeUndefined() + }) + + it("clears confirm-password touched flag when the field is emptied", () => { + const { result } = renderHook(() => useCloudBackupForm()) + + act(() => result.current.toggleEncryption()) + act(() => result.current.setPassword("ValidPass1234!")) + act(() => result.current.setConfirmPassword("wrong")) + act(() => result.current.markConfirmPasswordTouched()) + + expect(result.current.confirmPasswordError).toBe("Passwords do not match") + + act(() => result.current.setConfirmPassword("")) + act(() => result.current.setConfirmPassword("s")) + + expect(result.current.confirmPasswordError).toBeUndefined() + }) + + it("shows password mismatch error after confirm field is marked touched", () => { const { result } = renderHook(() => useCloudBackupForm()) act(() => result.current.toggleEncryption()) act(() => result.current.setPassword("ValidPass1234!")) act(() => result.current.setConfirmPassword("different")) + act(() => result.current.markConfirmPasswordTouched()) expect(result.current.confirmPasswordError).toBe("Passwords do not match") expect(result.current.isValid).toBe(false) }) + it("does not show confirm password error while typing before the field is touched", () => { + const { result } = renderHook(() => useCloudBackupForm()) + + act(() => result.current.toggleEncryption()) + act(() => result.current.setPassword("ValidPass1234!")) + act(() => result.current.setConfirmPassword("different")) + + expect(result.current.confirmPasswordError).toBeUndefined() + expect(result.current.isValid).toBe(false) + }) + it("is valid when passwords match and meet minimum length", () => { const { result } = renderHook(() => useCloudBackupForm()) diff --git a/__tests__/screens/spark-onboarding/restore-phrase-screen.spec.tsx b/__tests__/screens/spark-onboarding/restore-phrase-screen.spec.tsx new file mode 100644 index 0000000000..99dd860496 --- /dev/null +++ b/__tests__/screens/spark-onboarding/restore-phrase-screen.spec.tsx @@ -0,0 +1,169 @@ +import React from "react" +import { render } from "@testing-library/react-native" + +import { SparkRestorePhraseScreen } from "@app/screens/spark-onboarding/restore/restore-phrase-screen" +import { loadLocale } from "@app/i18n/i18n-util.sync" +import { i18nObject } from "@app/i18n/i18n-util" + +import { ContextForScreen } from "../helper" + +const mockUseRestorePhrase = jest.fn() +jest.mock("@app/screens/spark-onboarding/restore/hooks/use-restore-phrase", () => ({ + useRestorePhrase: () => mockUseRestorePhrase(), + RestoreStatus: { Idle: "idle", Restoring: "restoring", Error: "error" }, +})) + +const mockNavigate = jest.fn() +const mockSetOptions = jest.fn() +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useNavigation: () => ({ navigate: mockNavigate, setOptions: mockSetOptions }), + useRoute: () => ({ params: { step: 2, words: Array(12).fill("") } }), +})) + +type MnemonicWordInputProps = { + index: number + value: string + placeholder: string + correct?: boolean + wrong?: boolean + testID?: string +} +const mockMnemonicWordInput = jest.fn(() => null) +jest.mock("@app/components/mnemonic-word-input", () => { + const ReactImpl = jest.requireActual("react") + const Mock = ReactImpl.forwardRef( + (props: MnemonicWordInputProps, _ref: React.Ref) => + mockMnemonicWordInput(props), + ) + Mock.displayName = "MockMnemonicWordInput" + return { MnemonicWordInput: Mock } +}) + +loadLocale("en") +const LL = i18nObject("en") + +const defaultHookReturn = { + stepWords: Array(6).fill(""), + offset: 6, + setActiveIndex: jest.fn(), + updateWord: jest.fn(), + handlePaste: jest.fn(() => false), + handlePasteFromClipboard: jest.fn(), + suggestions: [], + selectSuggestion: jest.fn(), + stepFilled: false, + allFilled: false, + isValid: false, + validationError: null as string | null, + status: "idle", + isStep1: false, + handleContinue: jest.fn(), + handleRestore: jest.fn(), + focusRequest: null as number | null, + clearFocusRequest: jest.fn(), + words: Array(12).fill(""), + activeIndex: 0, +} + +const renderScreen = () => + render( + + + , + ) + +describe("SparkRestorePhraseScreen", () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseRestorePhrase.mockReturnValue(defaultHookReturn) + }) + + it("renders the inline invalidMnemonic message when step 2 is fully filled but invalid", () => { + mockUseRestorePhrase.mockReturnValue({ + ...defaultHookReturn, + allFilled: true, + isValid: false, + }) + + const { getByText } = renderScreen() + + expect(getByText(LL.RestoreScreen.invalidMnemonic())).toBeTruthy() + }) + + it("does not render the inline invalidMnemonic message while still typing", () => { + mockUseRestorePhrase.mockReturnValue({ + ...defaultHookReturn, + allFilled: false, + isValid: false, + }) + + const { queryByText } = renderScreen() + + expect(queryByText(LL.RestoreScreen.invalidMnemonic())).toBeNull() + }) + + it("propagates wrong=true to MnemonicWordInput when showError is active", () => { + mockUseRestorePhrase.mockReturnValue({ + ...defaultHookReturn, + allFilled: true, + isValid: false, + }) + + renderScreen() + + expect(mockMnemonicWordInput).toHaveBeenCalled() + const lastCall = + mockMnemonicWordInput.mock.calls[mockMnemonicWordInput.mock.calls.length - 1] + expect(lastCall[0].wrong).toBe(true) + }) + + it("propagates wrong=false to MnemonicWordInput when no error is active", () => { + mockUseRestorePhrase.mockReturnValue({ + ...defaultHookReturn, + allFilled: false, + isValid: false, + validationError: null, + }) + + renderScreen() + + const lastCall = + mockMnemonicWordInput.mock.calls[mockMnemonicWordInput.mock.calls.length - 1] + expect(lastCall[0].wrong).toBe(false) + }) + + it("renders the restoring spinner while status is Restoring", () => { + mockUseRestorePhrase.mockReturnValue({ + ...defaultHookReturn, + status: "restoring", + }) + + const { getByText } = renderScreen() + + expect(getByText(LL.RestoreScreen.restoring())).toBeTruthy() + }) + + it("renders the error screen with retry CTA when status is Error", () => { + mockUseRestorePhrase.mockReturnValue({ + ...defaultHookReturn, + status: "error", + }) + + const { getByText } = renderScreen() + + expect(getByText(LL.RestoreScreen.restoreFailed())).toBeTruthy() + expect(getByText(LL.common.tryAgain())).toBeTruthy() + }) + + it("renders a custom validationError text when set", () => { + mockUseRestorePhrase.mockReturnValue({ + ...defaultHookReturn, + validationError: "Custom validation error", + }) + + const { getByText } = renderScreen() + + expect(getByText("Custom validation error")).toBeTruthy() + }) +}) diff --git a/__tests__/screens/spark-onboarding/restore/hooks/use-restore-phrase.spec.ts b/__tests__/screens/spark-onboarding/restore/hooks/use-restore-phrase.spec.ts index 1d68c09859..bfceb0e4d7 100644 --- a/__tests__/screens/spark-onboarding/restore/hooks/use-restore-phrase.spec.ts +++ b/__tests__/screens/spark-onboarding/restore/hooks/use-restore-phrase.spec.ts @@ -18,6 +18,7 @@ jest.mock("@react-native-clipboard/clipboard", () => ({ jest.mock("bip39", () => ({ validateMnemonic: (m: string) => m.split(" ").length === 12 && m.startsWith("valid"), + wordlists: { english: [] }, })) const mockUpdateWord = jest.fn() @@ -35,6 +36,8 @@ let mockBip39State = { selectSuggestion: jest.fn(), stepFilled: false, allFilled: false, + focusRequest: null as number | null, + clearFocusRequest: jest.fn(), } jest.mock("@app/hooks/use-bip39-input", () => ({ @@ -74,6 +77,8 @@ describe("useRestorePhrase", () => { selectSuggestion: jest.fn(), stepFilled: false, allFilled: false, + focusRequest: null as number | null, + clearFocusRequest: jest.fn(), } }) @@ -110,6 +115,49 @@ describe("useRestorePhrase", () => { expect(mockHandlePaste).toHaveBeenCalledWith("word1 word2 word3") }) + it("auto-navigates to step 2 when full valid phrase is pasted in step 1", () => { + mockHandlePaste.mockReturnValue(true) + const fullPhrase = "valid a b c d e f g h i j k" + + const { result } = renderHook(() => useRestorePhrase({ step: PhraseStep.First })) + + let returned: boolean | undefined + act(() => { + returned = result.current.handlePaste(fullPhrase) + }) + + expect(returned).toBe(true) + expect(mockNavigate).toHaveBeenCalledWith("sparkRestorePhraseScreen", { + step: PhraseStep.Second, + words: fullPhrase.split(" "), + }) + }) + + it("does not auto-navigate when paste happens on step 2", () => { + mockHandlePaste.mockReturnValue(true) + const fullPhrase = "valid a b c d e f g h i j k" + + const { result } = renderHook(() => useRestorePhrase({ step: PhraseStep.Second })) + + act(() => { + result.current.handlePaste(fullPhrase) + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it("does not auto-navigate when paste yields invalid mnemonic", () => { + mockHandlePaste.mockReturnValue(true) + + const { result } = renderHook(() => useRestorePhrase({ step: PhraseStep.First })) + + act(() => { + result.current.handlePaste("invalid a b c d e f g h i j k") + }) + + expect(mockNavigate).not.toHaveBeenCalled() + }) + it("does not paste when clipboard is empty", async () => { mockGetString.mockResolvedValue("") diff --git a/__tests__/screens/spark-onboarding/restore/hooks/use-restore-wallet.spec.ts b/__tests__/screens/spark-onboarding/restore/hooks/use-restore-wallet.spec.ts index 8d1e877858..1b27fd98fb 100644 --- a/__tests__/screens/spark-onboarding/restore/hooks/use-restore-wallet.spec.ts +++ b/__tests__/screens/spark-onboarding/restore/hooks/use-restore-wallet.spec.ts @@ -12,7 +12,7 @@ const mockDeleteMnemonic = jest.fn() const mockRecordError = jest.fn() const mockToastShow = jest.fn() const mockReinitSdk = jest.fn() -const mockResetBackupState = jest.fn() +const mockSetBackupCompleted = jest.fn() jest.mock("@app/self-custodial/bridge", () => ({ selfCustodialRestoreWallet: (...args: string[]) => mockRestore(...args), @@ -41,7 +41,8 @@ jest.mock("@app/self-custodial/providers/wallet-provider", () => ({ })) jest.mock("@app/self-custodial/providers/backup-state-provider", () => ({ - useBackupState: () => ({ resetBackupState: mockResetBackupState }), + useBackupState: () => ({ setBackupCompleted: mockSetBackupCompleted }), + BackupMethod: { Manual: "manual", Recovery: "recovery" }, })) jest.mock("@app/utils/toast", () => ({ @@ -82,7 +83,7 @@ describe("useRestoreWallet", () => { expect(mockRestore).toHaveBeenCalledWith("word1 word2 word3") expect(mockUpdateState).toHaveBeenCalledTimes(1) expect(mockReinitSdk).toHaveBeenCalledTimes(1) - expect(mockResetBackupState).toHaveBeenCalledTimes(1) + expect(mockSetBackupCompleted).toHaveBeenCalledWith("manual") expect(mockNavigate).toHaveBeenCalledWith("sparkBackupSuccessScreen") }) diff --git a/__tests__/screens/spark-onboarding/restore/restore-method-screen.spec.tsx b/__tests__/screens/spark-onboarding/restore/restore-method-screen.spec.tsx new file mode 100644 index 0000000000..4355cb38f0 --- /dev/null +++ b/__tests__/screens/spark-onboarding/restore/restore-method-screen.spec.tsx @@ -0,0 +1,68 @@ +import React from "react" +import { render } from "@testing-library/react-native" +import { Pressable, Text } from "react-native" + +import { IconHero } from "@app/components/icon-hero" +import { SparkRestoreMethodScreen } from "@app/screens/spark-onboarding/restore/restore-method-screen" +import theme from "@app/rne-theme/theme" + +import { ContextForScreen } from "../../helper" + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useNavigation: () => ({ navigate: jest.fn() }), +})) + +jest.mock("@app/hooks", () => ({ + useAppConfig: () => ({ appConfig: { galoyInstance: { name: "Main" } } }), + useKeychainBackup: () => ({ read: jest.fn(), loading: false }), +})) + +jest.mock("@app/screens/spark-onboarding/restore/hooks/use-restore-wallet", () => ({ + useRestoreWallet: () => ({ restore: jest.fn() }), +})) + +jest.mock("@app/components/atomic/galoy-primary-button", () => ({ + GaloyPrimaryButton: ({ title, onPress }: { title: string; onPress: () => void }) => ( + + {title} + + ), +})) + +jest.mock("@app/components/atomic/galoy-secondary-button", () => ({ + GaloySecondaryButton: ({ title, onPress }: { title: string; onPress: () => void }) => ( + + {title} + + ), +})) + +jest.mock("@app/components/icon-hero", () => ({ + IconHero: jest.fn(({ title, subtitle }: { title: string; subtitle: string }) => ( + <> + {title} + {subtitle} + + )), +})) + +describe("SparkRestoreMethodScreen", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders the hero icon with the success color", () => { + render( + + + , + ) + + const iconHeroMock = IconHero as unknown as jest.Mock + const props = iconHeroMock.mock.calls[0][0] + + expect(props.iconColor).toBe(theme.lightColors?.success) + expect(props.icon).toBe("cloud") + }) +}) diff --git a/__tests__/self-custodial/adapters/payment-adapter.spec.ts b/__tests__/self-custodial/adapters/payment-adapter.spec.ts index 638bcaea73..d9372e28e6 100644 --- a/__tests__/self-custodial/adapters/payment-adapter.spec.ts +++ b/__tests__/self-custodial/adapters/payment-adapter.spec.ts @@ -5,6 +5,13 @@ import { } from "@app/self-custodial/adapters/payment-adapter" import { createReceiveLightning, createReceiveOnchain } from "@app/self-custodial/bridge" +const mockRecordError = jest.fn() + +jest.mock("@react-native-firebase/crashlytics", () => ({ + __esModule: true, + default: () => ({ recordError: mockRecordError, log: jest.fn() }), +})) + jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ BitcoinNetwork: { Bitcoin: 0, Regtest: 4 }, InputType_Tags: { SparkAddress: "SparkAddress" }, @@ -181,6 +188,18 @@ describe("self-custodial payment adapters", () => { expect(result).toBeNull() }) + it("records the rejection to crashlytics so the failure is queryable, not just a breadcrumb (Important #2)", async () => { + const sdk = createMockSdk() + sdk.prepareSendPayment.mockRejectedValue(new Error("fee quote boom")) + mockRecordError.mockClear() + + const getFee = createGetFee(sdk as never) + await getFee({ destination: "lnbc1..." }) + + expect(mockRecordError).toHaveBeenCalledTimes(1) + expect(mockRecordError.mock.calls[0][0].message).toContain("fee quote boom") + }) + it("prepares with tokenIdentifier + USDB-scaled amount when currency is USD", async () => { const sdk = createMockSdk() sdk.prepareSendPayment.mockResolvedValue({ amount: BigInt(100) }) diff --git a/__tests__/self-custodial/bridge/status.spec.ts b/__tests__/self-custodial/bridge/status.spec.ts index 3ebaddb585..87dcd6ea66 100644 --- a/__tests__/self-custodial/bridge/status.spec.ts +++ b/__tests__/self-custodial/bridge/status.spec.ts @@ -12,7 +12,7 @@ jest.mock("@breeztech/breez-sdk-spark-react-native", () => ({ Unknown: 3, Major: 4, }, - getSparkStatus: () => mockBreezGetSparkStatus(), + getSparkStatus: (...args: unknown[]) => mockBreezGetSparkStatus(...args), })) describe("getSparkStatus (bridge)", () => { @@ -37,4 +37,27 @@ describe("getSparkStatus (bridge)", () => { await expect(getSparkStatus()).rejects.toThrow("network") }) + + it("forwards an AbortSignal to the SDK when provided (Important #8)", async () => { + mockBreezGetSparkStatus.mockResolvedValue({ + status: ServiceStatus.Operational, + lastUpdated: BigInt(0), + }) + const controller = new AbortController() + + await getSparkStatus(controller.signal) + + expect(mockBreezGetSparkStatus).toHaveBeenCalledWith({ signal: controller.signal }) + }) + + it("calls the SDK without arguments when no signal is provided", async () => { + mockBreezGetSparkStatus.mockResolvedValue({ + status: ServiceStatus.Operational, + lastUpdated: BigInt(0), + }) + + await getSparkStatus() + + expect(mockBreezGetSparkStatus).toHaveBeenCalledWith() + }) }) diff --git a/__tests__/self-custodial/hooks/use-payment-request.spec.ts b/__tests__/self-custodial/hooks/use-payment-request.spec.ts index 4dbdbf892f..6ce8042009 100644 --- a/__tests__/self-custodial/hooks/use-payment-request.spec.ts +++ b/__tests__/self-custodial/hooks/use-payment-request.spec.ts @@ -8,12 +8,18 @@ const mockReceiveOnchain = jest.fn() const mockSelfCustodialWallet = jest.fn() const mockActiveWallet = jest.fn() const mockConvertMoneyAmount = jest.fn() +const mockRecordError = jest.fn() jest.mock("@app/self-custodial/bridge", () => ({ createReceiveLightning: () => mockReceiveLightning, createReceiveOnchain: () => mockReceiveOnchain, })) +jest.mock("@react-native-firebase/crashlytics", () => ({ + __esModule: true, + default: () => ({ recordError: mockRecordError, log: jest.fn() }), +})) + jest.mock("@app/self-custodial/providers/wallet-provider", () => ({ useSelfCustodialWallet: () => mockSelfCustodialWallet(), })) @@ -121,6 +127,20 @@ describe("usePaymentRequest", () => { }) }) + it("records the rejection to crashlytics when the receive adapter throws (Important #4)", async () => { + mockReceiveLightning.mockRejectedValue(new Error("invoice generation boom")) + + const { result } = renderHook(() => usePaymentRequest()) + + await waitFor(() => { + expect(result.current?.state).toBe("Error") + }) + + expect(mockRecordError).toHaveBeenCalledWith( + expect.objectContaining({ message: "invoice generation boom" }), + ) + }) + it("does not call the receive adapter while active wallet is not ready", () => { mockActiveWallet.mockReturnValue({ wallets: [btcWallet, usdWallet], @@ -140,7 +160,7 @@ describe("usePaymentRequest", () => { }) }) - it("getFullUriFn returns prefixed lightning URI", async () => { + it("getFullUriFn returns raw lightning invoice without `lightning:` prefix even when prefix is requested", async () => { const { result } = renderHook(() => usePaymentRequest()) await waitFor(() => { @@ -151,7 +171,7 @@ describe("usePaymentRequest", () => { prefix: true, uppercase: false, }) - expect(uri).toBe("lightning:lnbc1test...") + expect(uri).toBe("lnbc1test...") }) it("getFullUriFn returns raw invoice without prefix", async () => { @@ -168,6 +188,17 @@ describe("usePaymentRequest", () => { expect(uri).toBe("lnbc1test...") }) + it("getFullUriFn returns uppercased invoice when requested", async () => { + const { result } = renderHook(() => usePaymentRequest()) + + await waitFor(() => { + expect(result.current?.state).toBe("Created") + }) + + const uri = result.current?.pr?.info?.data?.getFullUriFn({ uppercase: true }) + expect(uri).toBe("LNBC1TEST...") + }) + it("getCopyableInvoiceFn returns payment request", async () => { const { result } = renderHook(() => usePaymentRequest()) diff --git a/__tests__/self-custodial/providers/is-online.spec.ts b/__tests__/self-custodial/providers/is-online.spec.ts index 112037d9fb..ead24b6440 100644 --- a/__tests__/self-custodial/providers/is-online.spec.ts +++ b/__tests__/self-custodial/providers/is-online.spec.ts @@ -5,6 +5,7 @@ import { getServiceStatus, isOnline, isOnlineStatus, + STATUS_TIMEOUT_MS, } from "@app/self-custodial/providers/is-online" const mockGetSparkStatus = jest.fn() @@ -16,7 +17,7 @@ jest.mock("@react-native-firebase/crashlytics", () => ({ })) jest.mock("@app/self-custodial/bridge", () => ({ - getSparkStatus: () => mockGetSparkStatus(), + getSparkStatus: (signal?: AbortSignal) => mockGetSparkStatus(signal), })) const loadFreshIsOnlineModule = () => { @@ -27,6 +28,12 @@ const loadFreshIsOnlineModule = () => { return mod! } +describe("STATUS_TIMEOUT_MS", () => { + it("exposes the shared spark-status timeout for callers that share the budget", () => { + expect(STATUS_TIMEOUT_MS).toBe(5000) + }) +}) + describe("getServiceStatus", () => { beforeEach(() => { jest.clearAllMocks() @@ -53,6 +60,36 @@ describe("getServiceStatus", () => { expect(await getServiceStatus()).toBe(ServiceStatus.Major) }) + + it("forwards an AbortSignal to getSparkStatus", async () => { + mockGetSparkStatus.mockResolvedValue({ + status: ServiceStatus.Operational, + lastUpdated: BigInt(0), + }) + + await getServiceStatus() + + const signal = mockGetSparkStatus.mock.calls[0][0] + expect(signal).toBeInstanceOf(AbortSignal) + expect(signal.aborted).toBe(false) + }) + + it("aborts and returns Major when the SDK call hangs past the timeout", async () => { + jest.useFakeTimers() + mockGetSparkStatus.mockImplementation( + (signal: AbortSignal) => + new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted"))) + }), + ) + + const promise = getServiceStatus() + jest.runAllTimers() + const result = await promise + + expect(result).toBe(ServiceStatus.Major) + jest.useRealTimers() + }) }) describe("isOnlineStatus", () => { @@ -155,6 +192,36 @@ describe("getOnlineState (3-state, Critical #4)", () => { expect(await getOnlineState()).toBe("unknown") }) + + it("forwards an AbortSignal to getSparkStatus (Critical #3)", async () => { + mockGetSparkStatus.mockResolvedValue({ + status: ServiceStatus.Operational, + lastUpdated: BigInt(0), + }) + + await getOnlineState() + + const signal = mockGetSparkStatus.mock.calls[0][0] + expect(signal).toBeInstanceOf(AbortSignal) + expect(signal.aborted).toBe(false) + }) + + it("aborts and returns 'unknown' when the SDK call hangs past the timeout (Critical #3)", async () => { + jest.useFakeTimers() + mockGetSparkStatus.mockImplementation( + (signal: AbortSignal) => + new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted"))) + }), + ) + + const promise = getOnlineState() + jest.runAllTimers() + const result = await promise + + expect(result).toBe("unknown") + jest.useRealTimers() + }) }) describe("crashlytics reporting on Spark status failures (I4)", () => { diff --git a/__tests__/self-custodial/providers/wallet-provider.spec.tsx b/__tests__/self-custodial/providers/wallet-provider.spec.tsx index 751e02b5f8..29c2fd9aef 100644 --- a/__tests__/self-custodial/providers/wallet-provider.spec.tsx +++ b/__tests__/self-custodial/providers/wallet-provider.spec.tsx @@ -82,6 +82,7 @@ jest.mock("@app/self-custodial/providers/validate-network", () => ({ })) jest.mock("@app/self-custodial/providers/is-online", () => { + // Mirror ServiceStatus enum ordinals from the SDK mock. const Operational = 0 const Degraded = 1 return { @@ -119,12 +120,6 @@ describe("SelfCustodialWalletProvider", () => { stableBalanceActiveLabel: undefined, sparkPrivateModeEnabled: false, }) - jest - .requireMock("@app/self-custodial/providers/is-online") - .getOnlineState.mockResolvedValue("online") - jest - .requireMock("@app/self-custodial/providers/is-online") - .isOnline.mockResolvedValue(true) }) it("renders children", () => { @@ -327,11 +322,14 @@ describe("SelfCustodialWalletProvider", () => { expect(mockAddSdkEventListener).toHaveBeenCalled() }) + // Fire event while first refresh is in-flight listener.current?.({ tag: "Synced" }) + // Resolve first refresh resolveFirst!() await waitFor(() => { + // Initial refresh + event-triggered coalesced refresh expect(getSelfCustodialWalletSnapshot).toHaveBeenCalledTimes(2) }) }) @@ -398,16 +396,19 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.lastReceivedPaymentId).toBeNull() }) - it("transitions Ready→Offline when network goes down after being Ready", async () => { + it("transitions Ready→Offline when snapshot fails and service status reports offline", async () => { const { listener } = setupConnectedWallet({ getMnemonic: mockGetMnemonic, initSdk: mockInitSdk, addSdkEventListener: mockAddSdkEventListener, }) + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - getOnlineStateMock.mockResolvedValueOnce("online").mockResolvedValueOnce("offline") + getOnlineStateMock.mockResolvedValue("offline") + + const { getSelfCustodialWalletSnapshot } = getWalletSnapshotMocks() const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) @@ -415,6 +416,9 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Ready) }) + getSelfCustodialWalletSnapshot.mockReset() + getSelfCustodialWalletSnapshot.mockRejectedValue(new Error("snapshot failed")) + await act(async () => { await listener.current?.({ tag: "Synced" }) }) @@ -434,6 +438,11 @@ describe("SelfCustodialWalletProvider", () => { getSelfCustodialWalletSnapshot.mockReset() getSelfCustodialWalletSnapshot.mockRejectedValue(new Error("initial sync failed")) + const getOnlineStateMock = jest.requireMock( + "@app/self-custodial/providers/is-online", + ).getOnlineState + getOnlineStateMock.mockResolvedValue("online") + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) await waitFor(() => { @@ -454,6 +463,10 @@ describe("SelfCustodialWalletProvider", () => { addSdkEventListener: mockAddSdkEventListener, }) const { getSelfCustodialWalletSnapshot } = getWalletSnapshotMocks() + const getOnlineStateMock = jest.requireMock( + "@app/self-custodial/providers/is-online", + ).getOnlineState + getOnlineStateMock.mockResolvedValue("online") const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) @@ -462,6 +475,7 @@ describe("SelfCustodialWalletProvider", () => { }) getSelfCustodialWalletSnapshot.mockRejectedValueOnce(new Error("transient sync fail")) + getOnlineStateMock.mockResolvedValueOnce("online") await act(async () => { await listener.current?.({ tag: "Synced" }) @@ -503,10 +517,10 @@ describe("SelfCustodialWalletProvider", () => { initSdk: mockInitSdk, addSdkEventListener: mockAddSdkEventListener, }) + const { getSelfCustodialWalletSnapshot } = getWalletSnapshotMocks() const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - getOnlineStateMock.mockResolvedValueOnce("online").mockResolvedValueOnce("unknown") const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) @@ -514,6 +528,9 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Ready) }) + getSelfCustodialWalletSnapshot.mockRejectedValueOnce(new Error("transient")) + getOnlineStateMock.mockResolvedValueOnce("unknown") + await act(async () => { await listener.current?.({ tag: "Synced" }) }) @@ -521,19 +538,19 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Ready) }) - it("transitions Offline→Ready when network returns after being Offline", async () => { + it("transitions Offline→Ready when a subsequent snapshot succeeds", async () => { const { listener } = setupConnectedWallet({ getMnemonic: mockGetMnemonic, initSdk: mockInitSdk, addSdkEventListener: mockAddSdkEventListener, }) + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - getOnlineStateMock - .mockResolvedValueOnce("online") - .mockResolvedValueOnce("offline") - .mockResolvedValueOnce("online") + getOnlineStateMock.mockResolvedValue("offline") + + const { getSelfCustodialWalletSnapshot } = getWalletSnapshotMocks() const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) @@ -541,6 +558,10 @@ describe("SelfCustodialWalletProvider", () => { expect(result.current.status).toBe(ActiveWalletStatus.Ready) }) + getSelfCustodialWalletSnapshot.mockReset() + getSelfCustodialWalletSnapshot.mockRejectedValueOnce(new Error("snapshot failed")) + getSelfCustodialWalletSnapshot.mockResolvedValue({ wallets: [], hasMore: false }) + await act(async () => { await listener.current?.({ tag: "Synced" }) }) @@ -571,12 +592,73 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () stableBalanceActiveLabel: undefined, sparkPrivateModeEnabled: false, }) - jest - .requireMock("@app/self-custodial/providers/is-online") - .getOnlineState.mockResolvedValue("online") - jest - .requireMock("@app/self-custodial/providers/is-online") - .isOnline.mockResolvedValue(true) + }) + + it("recovers from a snapshot that hangs past the timeout instead of staying in Loading (Critical #2)", async () => { + jest.useFakeTimers() + setupConnectedWallet( + { + getMnemonic: mockGetMnemonic, + initSdk: mockInitSdk, + addSdkEventListener: mockAddSdkEventListener, + }, + { wallets: [], hasMore: false }, + ) + const snapshot = getWalletSnapshotMocks() + snapshot.getSelfCustodialWalletSnapshot.mockImplementation( + () => + new Promise(() => { + // never resolves; should be aborted by the 5s timeout race + }), + ) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(mockInitSdk).toHaveBeenCalled() + }) + + await act(async () => { + jest.advanceTimersByTime(5_001) + }) + + await waitFor(() => { + expect(result.current.status).not.toBe(ActiveWalletStatus.Loading) + }) + + expect(mockCrashlyticsRecordError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("wallet snapshot timed out"), + }), + ) + + jest.useRealTimers() + }) + + it("transitions out of Loading to Error when both snapshot and connectivity check fail (Critical #4)", async () => { + const isOnline = jest.requireMock("@app/self-custodial/providers/is-online") + isOnline.getOnlineState.mockResolvedValueOnce("unknown") + + setupConnectedWallet( + { + getMnemonic: mockGetMnemonic, + initSdk: mockInitSdk, + addSdkEventListener: mockAddSdkEventListener, + }, + { wallets: [], hasMore: false }, + ) + const snapshot = getWalletSnapshotMocks() + snapshot.getSelfCustodialWalletSnapshot.mockRejectedValueOnce( + new Error("snapshot failed"), + ) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => { + expect(result.current.status).toBe(ActiveWalletStatus.Error) + }) + + expect(result.current.status).not.toBe(ActiveWalletStatus.Loading) }) it("loadMore calls loadMoreTransactions and appends via appendTransactions", async () => { @@ -617,11 +699,7 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () resolveInit = resolve }), ) - mockGetMnemonicForAccount.mockResolvedValue("word1 word2 word3") - mockListSelfCustodialAccounts.mockResolvedValue([ - { id: "test-sc-uuid", lightningAddress: null }, - ]) - mockState.activeAccountId = "test-sc-uuid" + mockGetMnemonic.mockResolvedValue("word1 word2 word3") const fakeSdk = { id: "fake-sdk" } const { unmount } = renderHook(() => useSelfCustodialWallet(), { wrapper }) @@ -646,11 +724,7 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () it("preserves the loadMore cursor across refresh by passing the current raw offset to the snapshot (Critical #8)", async () => { const { listener } = setupConnectedWallet( { - getMnemonicForAccount: mockGetMnemonicForAccount, - listSelfCustodialAccounts: mockListSelfCustodialAccounts, - setActiveAccountId: (id: string) => { - mockState.activeAccountId = id - }, + getMnemonic: mockGetMnemonic, initSdk: mockInitSdk, addSdkEventListener: mockAddSdkEventListener, }, @@ -783,12 +857,14 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () it("keeps isBalanceStale=true when status transitions to Offline (sticky, only a fresh snapshot clears it)", async () => { const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") - snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue(buildStaleSnapshot()) + snapshot.getSelfCustodialWalletSnapshot.mockReset() + snapshot.getSelfCustodialWalletSnapshot.mockResolvedValueOnce(buildStaleSnapshot()) + snapshot.getSelfCustodialWalletSnapshot.mockRejectedValue(new Error("offline")) const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", ).getOnlineState - getOnlineStateMock.mockResolvedValueOnce("online").mockResolvedValueOnce("offline") + getOnlineStateMock.mockResolvedValue("offline") let capturedListener: (event: { tag: string }) => Promise mockAddSdkEventListener.mockImplementation( @@ -873,6 +949,7 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () expect(result.current.status).toBe(ActiveWalletStatus.Error) }) + // Trigger a manual refresh while offline — Error must stay Error await act(async () => { await result.current.refreshWallets() }) @@ -894,6 +971,9 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () expect(result.current.status).toBe(ActiveWalletStatus.Unavailable) }) + // refreshWallets returns early when sdkRef.current is null, so status + // never transitions here. This confirms the Unavailable path is untouched + // by offline detection. await act(async () => { await result.current.refreshWallets() }) @@ -901,12 +981,10 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () expect(result.current.status).toBe(ActiveWalletStatus.Unavailable) }) - it("transitions Loading→Offline when initial refresh detects offline", async () => { + it("transitions Loading→Offline when initial snapshot fails and service status is offline", async () => { const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") - snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue({ - wallets: [], - hasMore: false, - }) + snapshot.getSelfCustodialWalletSnapshot.mockReset() + snapshot.getSelfCustodialWalletSnapshot.mockRejectedValue(new Error("offline")) const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", @@ -922,16 +1000,11 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () expect(result.current.status).toBe(ActiveWalletStatus.Offline) }) - expect(snapshot.getSelfCustodialWalletSnapshot).not.toHaveBeenCalled() + expect(snapshot.getSelfCustodialWalletSnapshot).toHaveBeenCalled() }) it("polls refreshWallets every 10s while mounted", async () => { jest.useFakeTimers() - const { AppState } = jest.requireActual("react-native") - Object.defineProperty(AppState, "currentState", { - value: "active", - configurable: true, - }) const snapshot = jest.requireMock("@app/self-custodial/providers/wallet-snapshot") snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue({ wallets: [], @@ -943,6 +1016,7 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () ).getOnlineState getOnlineStateMock.mockResolvedValue("online") + const { AppState } = jest.requireActual("react-native") const prevAppState = AppState.currentState AppState.currentState = "active" @@ -951,26 +1025,33 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () renderHook(() => useSelfCustodialWallet(), { wrapper }) + // Flush pending async init await act(async () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() }) - const initialCalls = getOnlineStateMock.mock.calls.length + const initialCalls = snapshot.getSelfCustodialWalletSnapshot.mock.calls.length + // Advance 10 seconds: one more poll tick await act(async () => { jest.advanceTimersByTime(10000) await Promise.resolve() }) - expect(getOnlineStateMock.mock.calls.length).toBeGreaterThan(initialCalls) + expect(snapshot.getSelfCustodialWalletSnapshot.mock.calls.length).toBeGreaterThan( + initialCalls, + ) - const afterFirstTick = getOnlineStateMock.mock.calls.length + // Advance another 10 seconds: another tick + const afterFirstTick = snapshot.getSelfCustodialWalletSnapshot.mock.calls.length await act(async () => { jest.advanceTimersByTime(10000) await Promise.resolve() }) - expect(getOnlineStateMock.mock.calls.length).toBeGreaterThan(afterFirstTick) + expect(snapshot.getSelfCustodialWalletSnapshot.mock.calls.length).toBeGreaterThan( + afterFirstTick, + ) AppState.currentState = prevAppState jest.useRealTimers() @@ -983,11 +1064,10 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () wallets: [], hasMore: false, }) - const { ServiceStatus } = jest.requireMock("@breeztech/breez-sdk-spark-react-native") - const getServiceStatusMock = jest.requireMock( + const getOnlineStateMock = jest.requireMock( "@app/self-custodial/providers/is-online", - ).getServiceStatus - getServiceStatusMock.mockResolvedValue(ServiceStatus.Operational) + ).getOnlineState + getOnlineStateMock.mockResolvedValue("online") const { AppState } = jest.requireActual("react-native") const prevAppState = AppState.currentState @@ -1004,14 +1084,14 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () await Promise.resolve() }) - const initialCalls = getServiceStatusMock.mock.calls.length + const initialCalls = snapshot.getSelfCustodialWalletSnapshot.mock.calls.length await act(async () => { jest.advanceTimersByTime(10000) await Promise.resolve() }) - expect(getServiceStatusMock.mock.calls).toHaveLength(initialCalls) + expect(snapshot.getSelfCustodialWalletSnapshot.mock.calls).toHaveLength(initialCalls) AppState.currentState = prevAppState jest.useRealTimers() @@ -1042,14 +1122,15 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () }) unmount() - const afterUnmount = getOnlineStateMock.mock.calls.length + const afterUnmount = snapshot.getSelfCustodialWalletSnapshot.mock.calls.length + // Advance several intervals after unmount — should not trigger more calls await act(async () => { jest.advanceTimersByTime(60000) await Promise.resolve() }) - expect(getOnlineStateMock.mock.calls).toHaveLength(afterUnmount) + expect(snapshot.getSelfCustodialWalletSnapshot.mock.calls).toHaveLength(afterUnmount) jest.useRealTimers() }) @@ -1084,22 +1165,27 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () expect(addEventListenerSpy).toHaveBeenCalled() }) - const callsBefore = getOnlineStateMock.mock.calls.length + const callsBefore = snapshot.getSelfCustodialWalletSnapshot.mock.calls.length await act(async () => { listeners.forEach((fn) => fn("active")) await Promise.resolve() }) - expect(getOnlineStateMock.mock.calls.length).toBeGreaterThan(callsBefore) + expect(snapshot.getSelfCustodialWalletSnapshot.mock.calls.length).toBeGreaterThan( + callsBefore, + ) - const callsAfterActive = getOnlineStateMock.mock.calls.length + const callsAfterActive = snapshot.getSelfCustodialWalletSnapshot.mock.calls.length await act(async () => { listeners.forEach((fn) => fn("background")) await Promise.resolve() }) - expect(getOnlineStateMock.mock.calls).toHaveLength(callsAfterActive) + // Background transition should NOT trigger a refresh + expect(snapshot.getSelfCustodialWalletSnapshot.mock.calls).toHaveLength( + callsAfterActive, + ) addEventListenerSpy.mockRestore() }) @@ -1200,3 +1286,69 @@ describe("SelfCustodialWalletProvider — async ops, connectivity & polling", () }) }) }) + +describe("SelfCustodialWalletProvider — stale-write safety (Critical #5)", () => { + beforeEach(() => { + jest.clearAllMocks() + mockGetMnemonic.mockResolvedValue(null) + mockGetMnemonicNetwork.mockResolvedValue("regtest") + mockInitSdk.mockRejectedValue(new Error("SDK not available in test")) + mockDisconnectSdk.mockResolvedValue(undefined) + mockAddSdkEventListener.mockResolvedValue("listener-id") + mockGetUserSettings.mockResolvedValue({ + stableBalanceActiveLabel: undefined, + sparkPrivateModeEnabled: false, + }) + }) + + it("ignores a stale snapshot that resolves after the SDK was replaced", async () => { + setupConnectedWallet( + { + getMnemonic: mockGetMnemonic, + initSdk: mockInitSdk, + addSdkEventListener: mockAddSdkEventListener, + }, + { wallets: [], hasMore: false }, + ) + const snapshot = getWalletSnapshotMocks() + + type StaleResolver = (value: { + wallets: unknown[] + hasMore: boolean + rawTransactionCount: number + }) => void + let resolveStale: StaleResolver | null = null + snapshot.getSelfCustodialWalletSnapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStale = resolve as unknown as StaleResolver + }), + ) + + const { result } = renderHook(() => useSelfCustodialWallet(), { wrapper }) + + await waitFor(() => expect(mockAddSdkEventListener).toHaveBeenCalled()) + + act(() => { + result.current.retry() + }) + + await waitFor(() => expect(mockDisconnectSdk).toHaveBeenCalled()) + + snapshot.getSelfCustodialWalletSnapshot.mockResolvedValue({ + wallets: [], + hasMore: false, + rawTransactionCount: 0, + }) + + await act(async () => { + resolveStale?.({ + wallets: [{ id: "stale" }], + hasMore: true, + rawTransactionCount: 99, + }) + }) + + expect(result.current.hasMoreTransactions).toBe(false) + }) +}) diff --git a/__tests__/utils/secure-storage-mnemonic.spec.ts b/__tests__/utils/secure-storage-mnemonic.spec.ts index a0f7654d22..43b37c0349 100644 --- a/__tests__/utils/secure-storage-mnemonic.spec.ts +++ b/__tests__/utils/secure-storage-mnemonic.spec.ts @@ -3,6 +3,7 @@ import KeyStoreWrapper from "@app/utils/storage/secureStorage" const mockGet = jest.fn() const mockSet = jest.fn() const mockRemove = jest.fn() +const mockRecordError = jest.fn() jest.mock("react-native-secure-key-store", () => ({ __esModule: true, @@ -17,6 +18,11 @@ jest.mock("react-native-secure-key-store", () => ({ }, })) +jest.mock("@react-native-firebase/crashlytics", () => ({ + __esModule: true, + default: () => ({ recordError: mockRecordError, log: jest.fn() }), +})) + describe("KeyStoreWrapper mnemonic methods", () => { beforeEach(() => { jest.clearAllMocks() @@ -118,6 +124,37 @@ describe("KeyStoreWrapper mnemonic methods", () => { expect(result).toBe(false) }) + + it("records crashlytics when the primary mnemonic removal fails (Important #5)", async () => { + mockRemove.mockRejectedValue(new Error("keystore unavailable")) + + await KeyStoreWrapper.deleteMnemonic() + + expect(mockRecordError).toHaveBeenCalledTimes(1) + expect(mockRecordError.mock.calls[0][0].message).toContain("keystore unavailable") + }) + + it("records crashlytics for the network-key removal failure but still returns true (Important #5)", async () => { + mockRemove + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("network key write-locked")) + + const result = await KeyStoreWrapper.deleteMnemonic() + + expect(result).toBe(true) + expect(mockRecordError).toHaveBeenCalledTimes(1) + expect(mockRecordError.mock.calls[0][0].message).toContain( + "network key write-locked", + ) + }) + + it("does not record crashlytics when both removals succeed", async () => { + mockRemove.mockResolvedValue(undefined) + + await KeyStoreWrapper.deleteMnemonic() + + expect(mockRecordError).not.toHaveBeenCalled() + }) }) describe("getMnemonicNetwork", () => { diff --git a/__tests__/utils/with-timeout.spec.ts b/__tests__/utils/with-timeout.spec.ts new file mode 100644 index 0000000000..b177f0078e --- /dev/null +++ b/__tests__/utils/with-timeout.spec.ts @@ -0,0 +1,43 @@ +import { withTimeout } from "@app/utils/with-timeout" + +describe("withTimeout", () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it("resolves with the underlying promise value when it settles before the timeout", async () => { + const result = await withTimeout(Promise.resolve("ok"), 1000, "task") + + expect(result).toBe("ok") + }) + + it("rejects with a labelled timeout error when the promise hangs past the limit", async () => { + const hung = new Promise(() => { + // intentionally never resolves + }) + + const racing = withTimeout(hung, 5000, "snapshot") + + jest.advanceTimersByTime(5000) + + await expect(racing).rejects.toThrow("snapshot timed out after 5000ms") + }) + + it("propagates the underlying rejection without altering its identity", async () => { + const cause = new Error("upstream failure") + + await expect(withTimeout(Promise.reject(cause), 1000, "task")).rejects.toBe(cause) + }) + + it("does not fire the timeout once the promise has settled", async () => { + const settled = withTimeout(Promise.resolve(42), 1000, "task") + + await expect(settled).resolves.toBe(42) + + jest.advanceTimersByTime(2000) + }) +}) diff --git a/app/components/mnemonic-word-input/index.ts b/app/components/mnemonic-word-input/index.ts index 6052828435..dad46737a6 100644 --- a/app/components/mnemonic-word-input/index.ts +++ b/app/components/mnemonic-word-input/index.ts @@ -1 +1,2 @@ export { MnemonicWordInput } from "./mnemonic-word-input" +export type { MnemonicWordInputHandle } from "./mnemonic-word-input" diff --git a/app/components/mnemonic-word-input/mnemonic-word-input.tsx b/app/components/mnemonic-word-input/mnemonic-word-input.tsx index 1e521db18a..8b4c93e80f 100644 --- a/app/components/mnemonic-word-input/mnemonic-word-input.tsx +++ b/app/components/mnemonic-word-input/mnemonic-word-input.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { forwardRef, useImperativeHandle, useRef } from "react" import { TextInput, View } from "react-native" import { makeStyles, Text, useTheme } from "@rn-vui/themed" @@ -17,25 +17,29 @@ type MnemonicWordInputProps = { testID?: string } -export const MnemonicWordInput: React.FC = ({ - index, - value, - placeholder, - onChangeText, - onFocus, - correct, - wrong, - testID, -}) => { +export type MnemonicWordInputHandle = { + focus: () => void +} + +export const MnemonicWordInput = forwardRef< + MnemonicWordInputHandle, + MnemonicWordInputProps +>(({ index, value, placeholder, onChangeText, onFocus, correct, wrong, testID }, ref) => { const styles = useStyles() const { theme: { colors }, } = useTheme() + const inputRef = useRef(null) + + useImperativeHandle(ref, () => ({ + focus: () => inputRef.current?.focus(), + })) return ( {value.trim().length > 0 && {index + 1}.} = ({ ) -} +}) + +MnemonicWordInput.displayName = "MnemonicWordInput" const useStyles = makeStyles(({ colors }) => ({ container: { diff --git a/app/components/password-input/password-input.tsx b/app/components/password-input/password-input.tsx index 0c26da3a10..8de8e64e8f 100644 --- a/app/components/password-input/password-input.tsx +++ b/app/components/password-input/password-input.tsx @@ -9,6 +9,7 @@ type PasswordInputProps = { label: string value: string onChangeText: (text: string) => void + onBlur?: () => void placeholder?: string error?: string } @@ -17,6 +18,7 @@ export const PasswordInput: React.FC = ({ label, value, onChangeText, + onBlur, placeholder, error, }) => { @@ -34,6 +36,7 @@ export const PasswordInput: React.FC = ({ style={styles.input} value={value} onChangeText={onChangeText} + onBlur={onBlur} placeholder={placeholder} placeholderTextColor={colors.grey2} accessibilityLabel={label} diff --git a/app/hooks/use-backup-nudge-state.ts b/app/hooks/use-backup-nudge-state.ts index 6bcb0b5473..f6fd4e0d3f 100644 --- a/app/hooks/use-backup-nudge-state.ts +++ b/app/hooks/use-backup-nudge-state.ts @@ -3,8 +3,8 @@ import { useCallback, useEffect, useMemo, useState } from "react" import AsyncStorage from "@react-native-async-storage/async-storage" import crashlytics from "@react-native-firebase/crashlytics" +import { useTotalBalance } from "@app/components/balance-header/use-total-balance" import { useRemoteConfig } from "@app/config/feature-flags-context" -import { WalletCurrency } from "@app/graphql/generated" import { useActiveWallet } from "@app/hooks/use-active-wallet" import { BackupStatus, @@ -49,28 +49,33 @@ export const useBackupNudgeState = (): BackupNudgeState => { const isBackedUp = backupState.status === BackupStatus.Completed const isSelfCustodial = activeWallet.accountType === AccountType.SelfCustodial - const btcBalance = useMemo(() => { - const btcWallet = activeWallet.wallets.find( - (w) => w.walletCurrency === WalletCurrency.Btc, - ) - return btcWallet?.balance.amount ?? 0 - }, [activeWallet.wallets]) + const walletsForTotal = useMemo( + () => + activeWallet.wallets.map((w) => ({ + id: w.id, + balance: w.balance.amount, + walletCurrency: w.walletCurrency, + })), + [activeWallet.wallets], + ) + + const { satsBalance } = useTotalBalance(walletsForTotal) const isDismissedRecently = dismissedAt !== null && Date.now() - dismissedAt < DISMISSAL_COOLDOWN_MS const shouldShowModal = - !isBackedUp && isSelfCustodial && loaded && btcBalance >= backupNudgeModalThreshold + !isBackedUp && isSelfCustodial && loaded && satsBalance >= backupNudgeModalThreshold const shouldShowBanner = !isBackedUp && isSelfCustodial && loaded && - btcBalance >= backupNudgeBannerThreshold && + satsBalance >= backupNudgeBannerThreshold && !shouldShowModal && !isDismissedRecently - const shouldShowSettingsBanner = !isBackedUp && isSelfCustodial && loaded + const shouldShowSettingsBanner = !isBackedUp && isSelfCustodial return { shouldShowBanner, diff --git a/app/hooks/use-bip39-input.ts b/app/hooks/use-bip39-input.ts index 5c9f602337..e4caa628c5 100644 --- a/app/hooks/use-bip39-input.ts +++ b/app/hooks/use-bip39-input.ts @@ -30,6 +30,7 @@ export const useBip39Input = ({ const [words, setWords] = useState(initialWords ?? Array(wordCount).fill("")) const [activeIndex, setActiveIndex] = useState(offset) const [keyboardVisible, setKeyboardVisible] = useState(false) + const [focusRequest, setFocusRequest] = useState(null) useEffect(() => { const showSub = Keyboard.addListener("keyboardDidShow", () => @@ -45,13 +46,25 @@ export const useBip39Input = ({ } }, []) - const updateWord = useCallback((index: number, value: string) => { - setWords((prev) => { - const next = [...prev] - next[index] = value.toLowerCase().trim() - return next - }) - }, []) + const updateWord = useCallback( + (index: number, value: string) => { + const normalized = value.toLowerCase().trim() + setWords((prev) => { + const next = [...prev] + next[index] = normalized + return next + }) + const lastIndexInStep = offset + wordsPerStep - 1 + if (!BIP39_WORD_SET.has(normalized)) return + if (index >= lastIndexInStep) return + const matches = BIP39_WORDLIST_EN.filter((w) => w.startsWith(normalized)) + if (matches.length !== 1) return + setFocusRequest(index + 1) + }, + [offset, wordsPerStep], + ) + + const clearFocusRequest = useCallback(() => setFocusRequest(null), []) const handlePaste = useCallback( (text: string) => { @@ -68,8 +81,9 @@ export const useBip39Input = ({ if (!keyboardVisible) return [] const current = words[activeIndex] if (!current || current.length < MIN_CHARS) return [] - if (BIP39_WORD_SET.has(current)) return [] - return getBip39Suggestions(current, { maxResults: MAX_SUGGESTIONS }) + const matches = getBip39Suggestions(current, { maxResults: MAX_SUGGESTIONS }) + if (matches.length === 1 && matches[0] === current) return [] + return matches }, [words, activeIndex, keyboardVisible]) const selectSuggestion = useCallback( @@ -99,5 +113,7 @@ export const useBip39Input = ({ selectSuggestion, stepFilled, allFilled, + focusRequest, + clearFocusRequest, } } diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index f706f71c9b..c573df8f08 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -2397,6 +2397,7 @@ const en: BaseTranslation = { addressScreen: "Ways to get paid", tapUserName: "Tap to set username", notifications: "Notifications", + recoveryMethod: "Recovery method", title: "Settings", darkMode: "Dark Mode", setToDark: "Mode: dark.", @@ -3641,7 +3642,7 @@ const en: BaseTranslation = { check3: "Nobody is asking me for this information via message or a call", }, Phrase: { - headerTitle: "Your backup phrase", + headerTitle: "Backup phrase", sparkCompatible: "This backup phrase works in any {sparkCompatibleLink: string}", sparkCompatibleLink: "Spark-compatible wallet", @@ -3657,6 +3658,7 @@ const en: BaseTranslation = { enterWord: "Enter word", enterWords: "Enter words", confirm: "Confirm", + incorrectWord: "Incorrect word, please check the order", }, Success: { title: "Welcome to non-custodial Blink", @@ -3697,7 +3699,7 @@ const en: BaseTranslation = { nextWords: "Next 6 words", paste: "Paste", enterWord: "Word", - invalidMnemonic: "Invalid backup phrase. Please check your words and try again.", + invalidMnemonic: "Invalid backup phrase. Please check if the word order is correct.", restoring: "Restoring your wallet...", restoreSuccess: "Wallet restored successfully", restoreFailed: "Failed to restore wallet. Please try again.", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index cc12b1ab02..14731d602b 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -7522,6 +7522,10 @@ type RootTranslation = { * N​o​t​i​f​i​c​a​t​i​o​n​s */ notifications: string + /** + * R​e​c​o​v​e​r​y​ ​m​e​t​h​o​d + */ + recoveryMethod: string /** * S​e​t​t​i​n​g​s */ @@ -11557,7 +11561,7 @@ type RootTranslation = { } Phrase: { /** - * Y​o​u​r​ ​b​a​c​k​u​p​ ​p​h​r​a​s​e + * B​a​c​k​u​p​ ​p​h​r​a​s​e */ headerTitle: string /** @@ -11611,6 +11615,10 @@ type RootTranslation = { * C​o​n​f​i​r​m */ confirm: string + /** + * I​n​c​o​r​r​e​c​t​ ​w​o​r​d​,​ ​p​l​e​a​s​e​ ​c​h​e​c​k​ ​t​h​e​ ​o​r​d​e​r + */ + incorrectWord: string } Success: { /** @@ -11726,7 +11734,7 @@ type RootTranslation = { */ enterWord: string /** - * I​n​v​a​l​i​d​ ​b​a​c​k​u​p​ ​p​h​r​a​s​e​.​ ​P​l​e​a​s​e​ ​c​h​e​c​k​ ​y​o​u​r​ ​w​o​r​d​s​ ​a​n​d​ ​t​r​y​ ​a​g​a​i​n​. + * I​n​v​a​l​i​d​ ​b​a​c​k​u​p​ ​p​h​r​a​s​e​.​ ​P​l​e​a​s​e​ ​c​h​e​c​k​ ​i​f​ ​t​h​e​ ​w​o​r​d​ ​o​r​d​e​r​ ​i​s​ ​c​o​r​r​e​c​t​. */ invalidMnemonic: string /** @@ -19530,6 +19538,10 @@ export type TranslationFunctions = { * Notifications */ notifications: () => LocalizedString + /** + * Recovery method + */ + recoveryMethod: () => LocalizedString /** * Settings */ @@ -23494,7 +23506,7 @@ export type TranslationFunctions = { } Phrase: { /** - * Your backup phrase + * Backup phrase */ headerTitle: () => LocalizedString /** @@ -23547,6 +23559,10 @@ export type TranslationFunctions = { * Confirm */ confirm: () => LocalizedString + /** + * Incorrect word, please check the order + */ + incorrectWord: () => LocalizedString } Success: { /** @@ -23662,7 +23678,7 @@ export type TranslationFunctions = { */ enterWord: () => LocalizedString /** - * Invalid backup phrase. Please check your words and try again. + * Invalid backup phrase. Please check if the word order is correct. */ invalidMnemonic: () => LocalizedString /** diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index be0ada78ea..e30470e72d 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -2327,6 +2327,7 @@ "addressScreen": "Ways to get paid", "tapUserName": "Tap to set username", "notifications": "Notifications", + "recoveryMethod": "Recovery method", "title": "Settings", "darkMode": "Dark Mode", "setToDark": "Mode: dark.", @@ -3494,7 +3495,7 @@ "check3": "Nobody is asking me for this information via message or a call" }, "Phrase": { - "headerTitle": "Your backup phrase", + "headerTitle": "Backup phrase", "sparkCompatible": "This backup phrase works in any {sparkCompatibleLink: string}", "sparkCompatibleLink": "Spark-compatible wallet", "copiedToast": "Backup phrase copied into clipboard", @@ -3508,7 +3509,8 @@ "subtitle": "A quick check if you have written it down correctly", "enterWord": "Enter word", "enterWords": "Enter words", - "confirm": "Confirm" + "confirm": "Confirm", + "incorrectWord": "Incorrect word, please check the order" }, "Success": { "title": "Welcome to non-custodial Blink" @@ -3545,7 +3547,7 @@ "nextWords": "Next 6 words", "paste": "Paste", "enterWord": "Word", - "invalidMnemonic": "Invalid backup phrase. Please check your words and try again.", + "invalidMnemonic": "Invalid backup phrase. Please check if the word order is correct.", "restoring": "Restoring your wallet...", "restoreSuccess": "Wallet restored successfully", "restoreFailed": "Failed to restore wallet. Please try again.", diff --git a/app/i18n/raw-i18n/translations/af.json b/app/i18n/raw-i18n/translations/af.json index 2014d92476..cc70e88a26 100644 --- a/app/i18n/raw-i18n/translations/af.json +++ b/app/i18n/raw-i18n/translations/af.json @@ -2346,6 +2346,7 @@ "addressScreen": "Maniere om betaal te word", "tapUserName": "Tik om gebruikersnaam te verander ", "notifications": "Notas", + "recoveryMethod": "Herstelmetode", "title": "Verstellings", "darkMode": "Donker agtergrond", "setToDark": "Modus: donker.", @@ -3511,7 +3512,7 @@ "check3": "Niemand vra my vir hierdie inligting via boodskap of oproep nie" }, "Phrase": { - "headerTitle": "Jou rugsteunfrase", + "headerTitle": "Rugsteunfrase", "sparkCompatible": "Hierdie rugsteunfrase werk in enige {sparkCompatibleLink}", "sparkCompatibleLink": "Spark-versoenbare beursie", "copiedToast": "Rugsteunfrase na knipbord gekopieer", @@ -3525,7 +3526,8 @@ "subtitle": "Vinnige kontrole of jy dit korrek neergeskryf het", "enterWord": "Voer woord in", "enterWords": "Voer woorde in", - "confirm": "Bevestig" + "confirm": "Bevestig", + "incorrectWord": "Verkeerde woord, kontroleer asseblief die volgorde" }, "Success": { "title": "Welkom by nie-bewaarder Blink" @@ -3583,7 +3585,7 @@ "nextWords": "Volgende 6 woorde", "paste": "Plak", "enterWord": "Woord", - "invalidMnemonic": "Ongeldige rugsteunfrase. Kontroleer asseblief jou woorde en probeer weer.", + "invalidMnemonic": "Ongeldige rugsteunfrase. Kontroleer asseblief of die woordvolgorde reg is.", "restoring": "Jou beursie word herstel...", "restoreSuccess": "Beursie suksesvol herstel", "restoreFailed": "Kon nie beursie herstel nie. Probeer asseblief weer.", diff --git a/app/i18n/raw-i18n/translations/ar.json b/app/i18n/raw-i18n/translations/ar.json index 07c269bfce..a013afa763 100644 --- a/app/i18n/raw-i18n/translations/ar.json +++ b/app/i18n/raw-i18n/translations/ar.json @@ -2346,6 +2346,7 @@ "addressScreen": "Ways to get paid", "tapUserName": "إضغط لتعيين اسم المستخدم", "notifications": "Notifications", + "recoveryMethod": "طريقة الاسترداد", "title": "تطبيق الإعدادات", "darkMode": "Dark Mode", "setToDark": "Mode: dark.", @@ -3522,7 +3523,8 @@ "subtitle": "فحص سريع إذا كنت قد كتبتها بشكل صحيح", "enterWord": "أدخل الكلمة", "enterWords": "أدخل الكلمات", - "confirm": "تأكيد" + "confirm": "تأكيد", + "incorrectWord": "كلمة غير صحيحة، يرجى التحقق من الترتيب" }, "Success": { "title": "مرحبًا بك في Blink غير الحافظة" @@ -3580,7 +3582,7 @@ "nextWords": "الكلمات الـ 6 التالية", "paste": "لصق", "enterWord": "كلمة", - "invalidMnemonic": "عبارة نسخ احتياطي غير صالحة. يرجى التحقق من كلماتك والمحاولة مرة أخرى.", + "invalidMnemonic": "عبارة نسخ احتياطي غير صحيحة. يرجى التحقق من صحة ترتيب الكلمات.", "restoring": "جاري استعادة محفظتك...", "restoreSuccess": "تمت استعادة المحفظة بنجاح", "restoreFailed": "فشل في استعادة المحفظة. يرجى المحاولة مرة أخرى.", diff --git a/app/i18n/raw-i18n/translations/ca.json b/app/i18n/raw-i18n/translations/ca.json index 8c3a2a567c..f38389abc0 100644 --- a/app/i18n/raw-i18n/translations/ca.json +++ b/app/i18n/raw-i18n/translations/ca.json @@ -2329,6 +2329,7 @@ "addressScreen": "Maneres de cobrar", "tapUserName": "Toca per definir el nom d'usuari", "notifications": "Notificacions", + "recoveryMethod": "Mètode de recuperació", "title": "Configuració", "darkMode": "Mode fosc", "setToDark": "Mode: fosc", @@ -3470,7 +3471,7 @@ "check3": "Ningú em demana aquesta informació per missatge o trucada" }, "Phrase": { - "headerTitle": "La teva frase de còpia de seguretat", + "headerTitle": "Frase de còpia de seguretat", "sparkCompatible": "Aquesta frase de còpia de seguretat funciona en qualsevol {sparkCompatibleLink}", "sparkCompatibleLink": "cartera compatible amb Spark", "copiedToast": "Frase de còpia de seguretat copiada al porta-retalls", @@ -3484,7 +3485,8 @@ "subtitle": "Una comprovació ràpida per veure si l'has escrita correctament", "enterWord": "Introdueix la paraula", "enterWords": "Introdueix les paraules", - "confirm": "Confirmar" + "confirm": "Confirmar", + "incorrectWord": "Paraula incorrecta, comprova l'ordre" }, "Success": { "title": "Benvingut a Blink sense custòdia" @@ -3542,7 +3544,7 @@ "nextWords": "Següents 6 paraules", "paste": "Enganxar", "enterWord": "Paraula", - "invalidMnemonic": "Frase de recuperació no vàlida. Si us plau, revisa les paraules i torna-ho a provar.", + "invalidMnemonic": "Frase de còpia de seguretat no vàlida. Comprova si l'ordre de les paraules és correcte.", "restoring": "Restaurant la teva cartera...", "restoreSuccess": "Cartera restaurada amb èxit", "restoreFailed": "No s'ha pogut restaurar la cartera. Si us plau, torna-ho a provar.", diff --git a/app/i18n/raw-i18n/translations/cs.json b/app/i18n/raw-i18n/translations/cs.json index 9e0397d643..24118c596c 100644 --- a/app/i18n/raw-i18n/translations/cs.json +++ b/app/i18n/raw-i18n/translations/cs.json @@ -2346,6 +2346,7 @@ "addressScreen": "Způsoby platby", "tapUserName": "Klepnutím na nastavíte uživatelské jméno", "notifications": "Notifications", + "recoveryMethod": "Metoda obnovy", "title": "Nastavení", "darkMode": "Tmavý režim", "setToDark": "Režim: tmavý", @@ -3511,7 +3512,7 @@ "check3": "Nikdo mě nežádá o tyto informace prostřednictvím zprávy nebo hovoru" }, "Phrase": { - "headerTitle": "Vaše záložní fráze", + "headerTitle": "Záložní fráze", "sparkCompatible": "Tato záložní fráze funguje v jakékoli {sparkCompatibleLink}", "sparkCompatibleLink": "peněžence kompatibilní se Spark", "copiedToast": "Záložní fráze zkopírována do schránky", @@ -3525,7 +3526,8 @@ "subtitle": "Rychlá kontrola, zda jste ji správně zapsali", "enterWord": "Zadejte slovo", "enterWords": "Zadejte slova", - "confirm": "Potvrdit" + "confirm": "Potvrdit", + "incorrectWord": "Nesprávné slovo, zkontroluj prosím pořadí" }, "Success": { "title": "Vítejte v necustodial Blink" @@ -3583,7 +3585,7 @@ "nextWords": "Dalších 6 slov", "paste": "Vložit", "enterWord": "Slovo", - "invalidMnemonic": "Neplatná zálohovací fráze. Zkontrolujte svá slova a zkuste to znovu.", + "invalidMnemonic": "Neplatná záložní fráze. Zkontrolujte prosím, zda je pořadí slov správné.", "restoring": "Obnovování vaší peněženky...", "restoreSuccess": "Peněženka úspěšně obnovena", "restoreFailed": "Obnova peněženky selhala. Zkuste to prosím znovu.", diff --git a/app/i18n/raw-i18n/translations/da.json b/app/i18n/raw-i18n/translations/da.json index e4d41d871e..d864ad85dc 100644 --- a/app/i18n/raw-i18n/translations/da.json +++ b/app/i18n/raw-i18n/translations/da.json @@ -2332,6 +2332,7 @@ "addressScreen": "Måder at modtage betaling", "tapUserName": "Tryk for at indstille brugernavn", "notifications": "Notifikationer", + "recoveryMethod": "Gendannelsesmetode", "title": "Indstillinger", "darkMode": "Mørk tilstand", "setToDark": "Tilstand: mørk.", @@ -3488,7 +3489,7 @@ "check3": "Ingen beder mig om disse oplysninger via besked eller opkald" }, "Phrase": { - "headerTitle": "Din backup-sætning", + "headerTitle": "Backup-frase", "sparkCompatible": "Denne backup-sætning fungerer i enhver {sparkCompatibleLink}", "sparkCompatibleLink": "Spark-kompatibel tegnebog", "copiedToast": "Backup-sætning kopieret til udklipsholder", @@ -3502,7 +3503,8 @@ "subtitle": "En hurtig kontrol om du har skrevet den korrekt ned", "enterWord": "Indtast ord", "enterWords": "Indtast ord", - "confirm": "Bekræft" + "confirm": "Bekræft", + "incorrectWord": "Forkert ord, kontroller venligst rækkefølgen" }, "Success": { "title": "Velkommen til non-custodial Blink" @@ -3560,7 +3562,7 @@ "nextWords": "Næste 6 ord", "paste": "Indsæt", "enterWord": "Ord", - "invalidMnemonic": "Ugyldig sikkerhedsfrase. Kontrollér venligst dine ord og prøv igen.", + "invalidMnemonic": "Ugyldig backup-frase. Kontroller venligst om ordrækkefølgen er korrekt.", "restoring": "Gendanner din tegnebog...", "restoreSuccess": "Tegnebog genoprettet", "restoreFailed": "Kunne ikke gendanne tegnebogen. Prøv venligst igen.", diff --git a/app/i18n/raw-i18n/translations/de.json b/app/i18n/raw-i18n/translations/de.json index 4cdc498602..07992e4b5d 100644 --- a/app/i18n/raw-i18n/translations/de.json +++ b/app/i18n/raw-i18n/translations/de.json @@ -2329,6 +2329,7 @@ "addressScreen": "Zahlungsmöglichkeiten", "tapUserName": "Antippen um einen Benutzernamen festzulegen", "notifications": "Benachrichtigungen", + "recoveryMethod": "Wiederherstellungsmethode", "title": "Einstellungen", "darkMode": "Dark Mode", "setToDark": "Bildschirm: abgedunkelt", @@ -3458,7 +3459,7 @@ "check3": "Niemand fragt mich nach diesen Informationen per Nachricht oder Anruf" }, "Phrase": { - "headerTitle": "Deine Sicherungswörter", + "headerTitle": "Backup-Phrase", "sparkCompatible": "Diese Sicherungswörter funktionieren in jeder {sparkCompatibleLink}", "sparkCompatibleLink": "Spark-kompatiblen Wallet", "copiedToast": "Sicherungswörter in die Zwischenablage kopiert", @@ -3472,7 +3473,8 @@ "subtitle": "Eine kurze Überprüfung, ob du sie richtig aufgeschrieben hast", "enterWord": "Wort eingeben", "enterWords": "Wörter eingeben", - "confirm": "Bestätigen" + "confirm": "Bestätigen", + "incorrectWord": "Falsches Wort, bitte überprüfe die Reihenfolge" }, "Success": { "title": "Willkommen bei nicht-verwaltetem Blink" @@ -3530,7 +3532,7 @@ "nextWords": "Nächste 6 Wörter", "paste": "Einfügen", "enterWord": "Wort", - "invalidMnemonic": "Ungültige Sicherungsphrase. Bitte überprüfe deine Wörter und versuche es erneut.", + "invalidMnemonic": "Ungültige Backup-Phrase. Bitte überprüfe, ob die Wortreihenfolge korrekt ist.", "restoring": "Wallet wird wiederhergestellt...", "restoreSuccess": "Wallet erfolgreich wiederhergestellt", "restoreFailed": "Wallet-Wiederherstellung fehlgeschlagen. Bitte versuche es erneut.", diff --git a/app/i18n/raw-i18n/translations/el.json b/app/i18n/raw-i18n/translations/el.json index 1ef9a2556e..c57ea2d5fb 100644 --- a/app/i18n/raw-i18n/translations/el.json +++ b/app/i18n/raw-i18n/translations/el.json @@ -2329,6 +2329,7 @@ "addressScreen": "Τρόποι για να πληρωθείτε ", "tapUserName": "Πατήστε για να ορίσετε το όνομα χρήστη", "notifications": "Ειδοποιήσεις", + "recoveryMethod": "Μέθοδος ανάκτησης", "title": "Ρυθμίσεις", "darkMode": "Σκοτεινή λειτουργία", "setToDark": "Λειτουργία: Σκοτεινή.", @@ -3470,7 +3471,7 @@ "check3": "Κανείς δεν μου ζητά αυτές τις πληροφορίες μέσω μηνύματος ή κλήσης" }, "Phrase": { - "headerTitle": "Η φράση αντιγράφου ασφαλείας", + "headerTitle": "Φράση αντιγράφου ασφαλείας", "sparkCompatible": "Αυτή η φράση λειτουργεί σε οποιοδήποτε {sparkCompatibleLink}", "sparkCompatibleLink": "πορτοφόλι συμβατό με Spark", "copiedToast": "Η φράση αντιγράφηκε στο πρόχειρο", @@ -3484,7 +3485,8 @@ "subtitle": "Γρήγορος έλεγχος αν την γράψατε σωστά", "enterWord": "Εισάγετε λέξη", "enterWords": "Εισάγετε λέξεις", - "confirm": "Επιβεβαίωση" + "confirm": "Επιβεβαίωση", + "incorrectWord": "Λάθος λέξη, παρακαλώ ελέγξτε τη σειρά" }, "Success": { "title": "Καλώς ήρθατε στο μη φυλασσόμενο Blink" @@ -3542,7 +3544,7 @@ "nextWords": "Επόμενες 6 λέξεις", "paste": "Επικόλληση", "enterWord": "Λέξη", - "invalidMnemonic": "Μη έγκυρη φράση ανάκτησης. Ελέγξτε τις λέξεις σας και δοκιμάστε ξανά.", + "invalidMnemonic": "Μη έγκυρη φράση αντιγράφου ασφαλείας. Παρακαλώ ελέγξτε αν η σειρά των λέξεων είναι σωστή.", "restoring": "Επαναφορά πορτοφολιού...", "restoreSuccess": "Το πορτοφόλι αποκαταστάθηκε επιτυχώς", "restoreFailed": "Η επαναφορά απέτυχε. Δοκιμάστε ξανά.", diff --git a/app/i18n/raw-i18n/translations/es.json b/app/i18n/raw-i18n/translations/es.json index 3d0a5edaac..58e83f91ef 100644 --- a/app/i18n/raw-i18n/translations/es.json +++ b/app/i18n/raw-i18n/translations/es.json @@ -2328,6 +2328,7 @@ "addressScreen": "Formas de recibir pagos", "tapUserName": "Ingrese aquí para configurarlo", "notifications": "Notificaciones", + "recoveryMethod": "Método de recuperación", "title": "Configuración", "darkMode": "Modo oscuro.", "setToDark": "Modo: oscuro.", @@ -3458,7 +3459,7 @@ "check3": "Nadie me está pidiendo esta información por mensaje o llamada" }, "Phrase": { - "headerTitle": "Tu frase de respaldo", + "headerTitle": "Frase de respaldo", "sparkCompatible": "Esta frase de respaldo funciona en cualquier {sparkCompatibleLink}", "sparkCompatibleLink": "billetera compatible con Spark", "copiedToast": "Frase de respaldo copiada al portapapeles", @@ -3472,7 +3473,8 @@ "subtitle": "Una verificación rápida de que la escribiste correctamente", "enterWord": "Ingresa la palabra", "enterWords": "Ingresa las palabras", - "confirm": "Confirmar" + "confirm": "Confirmar", + "incorrectWord": "Palabra incorrecta, revisa el orden" }, "Success": { "title": "Bienvenido a Blink sin custodia" @@ -3530,7 +3532,7 @@ "nextWords": "Siguientes 6 palabras", "paste": "Pegar", "enterWord": "Palabra", - "invalidMnemonic": "Frase de respaldo inválida. Por favor revisa tus palabras e intenta de nuevo.", + "invalidMnemonic": "Frase de respaldo no válida. Por favor verifica si el orden de las palabras es correcto.", "restoring": "Restaurando tu billetera...", "restoreSuccess": "Billetera restaurada exitosamente", "restoreFailed": "Error al restaurar la billetera. Por favor intenta de nuevo.", diff --git a/app/i18n/raw-i18n/translations/fr.json b/app/i18n/raw-i18n/translations/fr.json index dc33bcd111..66b7031eb7 100644 --- a/app/i18n/raw-i18n/translations/fr.json +++ b/app/i18n/raw-i18n/translations/fr.json @@ -2346,6 +2346,7 @@ "addressScreen": "Moyens de se faire payer", "tapUserName": "Appuyez pour définir l’identifiant", "notifications": "Notifications", + "recoveryMethod": "Méthode de récupération", "title": "Réglages", "darkMode": "Mode sombre", "setToDark": "Mode : sombre.", @@ -3499,7 +3500,7 @@ "check3": "Personne ne me demande ces informations par message ou appel" }, "Phrase": { - "headerTitle": "Votre phrase de sauvegarde", + "headerTitle": "Phrase de sauvegarde", "sparkCompatible": "Cette phrase de sauvegarde fonctionne dans tout {sparkCompatibleLink}", "sparkCompatibleLink": "portefeuille compatible Spark", "copiedToast": "Phrase de sauvegarde copiée dans le presse-papier", @@ -3513,7 +3514,8 @@ "subtitle": "Vérification rapide que vous l'avez bien notée", "enterWord": "Entrez le mot", "enterWords": "Entrez les mots", - "confirm": "Confirmer" + "confirm": "Confirmer", + "incorrectWord": "Mot incorrect, veuillez vérifier l'ordre" }, "Success": { "title": "Bienvenue sur Blink non-custodial" @@ -3571,7 +3573,7 @@ "nextWords": "6 mots suivants", "paste": "Coller", "enterWord": "Mot", - "invalidMnemonic": "Phrase de sauvegarde invalide. Veuillez vérifier vos mots et réessayer.", + "invalidMnemonic": "Phrase de sauvegarde invalide. Veuillez vérifier si l'ordre des mots est correct.", "restoring": "Restauration de votre portefeuille...", "restoreSuccess": "Portefeuille restauré avec succès", "restoreFailed": "Échec de la restauration. Veuillez réessayer.", diff --git a/app/i18n/raw-i18n/translations/hr.json b/app/i18n/raw-i18n/translations/hr.json index f5c11a8e5f..2f45084551 100644 --- a/app/i18n/raw-i18n/translations/hr.json +++ b/app/i18n/raw-i18n/translations/hr.json @@ -2346,6 +2346,7 @@ "addressScreen": "Načini plaćanja", "tapUserName": "Dodirnite za postavljanje korisničkog imena", "notifications": "Obavijesti", + "recoveryMethod": "Način oporavka", "title": "Postavke", "darkMode": "Tamni način rada", "setToDark": "Način rada: tamno.", @@ -3511,7 +3512,7 @@ "check3": "Nitko me ne traži ove informacije putem poruke ili poziva" }, "Phrase": { - "headerTitle": "Vaša sigurnosna fraza", + "headerTitle": "Pričuvna fraza", "sparkCompatible": "Ova sigurnosna fraza radi u svakom {sparkCompatibleLink}", "sparkCompatibleLink": "novčaniku kompatibilnom sa Spark", "copiedToast": "Sigurnosna fraza kopirana u međuspremnik", @@ -3525,7 +3526,8 @@ "subtitle": "Brza provjera jeste li je ispravno zapisali", "enterWord": "Unesite riječ", "enterWords": "Unesite riječi", - "confirm": "Potvrdi" + "confirm": "Potvrdi", + "incorrectWord": "Pogrešna riječ, provjerite redoslijed" }, "Success": { "title": "Dobrodošli u Blink bez skrbništva" @@ -3583,7 +3585,7 @@ "nextWords": "Sljedećih 6 riječi", "paste": "Zalijepi", "enterWord": "Riječ", - "invalidMnemonic": "Nevažeća sigurnosna fraza. Provjerite svoje riječi i pokušajte ponovo.", + "invalidMnemonic": "Neispravna pričuvna fraza. Provjerite je li redoslijed riječi točan.", "restoring": "Obnavljanje vašeg novčanika...", "restoreSuccess": "Novčanik uspješno obnovljen", "restoreFailed": "Obnova novčanika nije uspjela. Pokušajte ponovo.", diff --git a/app/i18n/raw-i18n/translations/hu.json b/app/i18n/raw-i18n/translations/hu.json index ac0d751eec..b6be518a8b 100644 --- a/app/i18n/raw-i18n/translations/hu.json +++ b/app/i18n/raw-i18n/translations/hu.json @@ -2329,6 +2329,7 @@ "addressScreen": "Fizetési módok", "tapUserName": "Koppints a felhasználónév beállításához", "notifications": "Értesítések", + "recoveryMethod": "Helyreállítási mód", "title": "Beállítások", "darkMode": "Sötét mód", "setToDark": "Mód: sötét.", @@ -3470,7 +3471,7 @@ "check3": "Senki nem kéri tőlem ezeket az információkat üzenetben vagy hívásban" }, "Phrase": { - "headerTitle": "A biztonsági mentési kifejezésed", + "headerTitle": "Biztonsági mentési kifejezés", "sparkCompatible": "Ez a kifejezés bármely {sparkCompatibleLink} működik", "sparkCompatibleLink": "Spark-kompatibilis pénztárcában", "copiedToast": "Biztonsági mentési kifejezés vágólapra másolva", @@ -3484,7 +3485,8 @@ "subtitle": "Gyors ellenőrzés, hogy helyesen írtad-e le", "enterWord": "Írd be a szót", "enterWords": "Írd be a szavakat", - "confirm": "Megerősítés" + "confirm": "Megerősítés", + "incorrectWord": "Helytelen szó, kérlek ellenőrizd a sorrendet" }, "Success": { "title": "Üdvözöljük a nem letétkezelő Blinkben" @@ -3542,7 +3544,7 @@ "nextWords": "Következő 6 szó", "paste": "Beillesztés", "enterWord": "Szó", - "invalidMnemonic": "Érvénytelen biztonsági kifejezés. Kérjük, ellenőrizd a szavakat és próbáld újra.", + "invalidMnemonic": "Érvénytelen biztonsági mentési kifejezés. Kérlek ellenőrizd, hogy a szavak sorrendje helyes-e.", "restoring": "Tárca visszaállítása...", "restoreSuccess": "Tárca sikeresen visszaállítva", "restoreFailed": "Nem sikerült visszaállítani a tárcát. Kérjük, próbáld újra.", diff --git a/app/i18n/raw-i18n/translations/hy.json b/app/i18n/raw-i18n/translations/hy.json index c2eca7e079..1c110d7c6e 100644 --- a/app/i18n/raw-i18n/translations/hy.json +++ b/app/i18n/raw-i18n/translations/hy.json @@ -2346,6 +2346,7 @@ "addressScreen": "Վճարվելու միջոցներ", "tapUserName": "Սեղմել՝ օգտանուն սահմանելու համար", "notifications": "Notifications", + "recoveryMethod": "Վերականգնման մեթոդ", "title": "Կարգավորումներ", "darkMode": "Գիշերային ռեժիմ", "setToDark": "Ռեժիմ՝ գիշերային։ ", @@ -3511,7 +3512,7 @@ "check3": "Ոչ ոք ինձնից այս տեղեկությունը չի խնդրում հաղորդագրությամբ կամ զանգով" }, "Phrase": { - "headerTitle": "Ձեր պահուստային արտահայտությունը", + "headerTitle": "Պահեստային արտահայտություն", "sparkCompatible": "Այս պահուստային արտահայտությունն աշխատում է ցանկացած {sparkCompatibleLink}-ում", "sparkCompatibleLink": "Spark-ի հետ համատեղելի դրամապանակ", "copiedToast": "Պահուստային արտահայտությունը պատճենվեց ժամանակավոր շտեմարանում", @@ -3525,7 +3526,8 @@ "subtitle": "Կարճ ստուգում՝ համոզվելու, որ այն ճիշտ եք գրել", "enterWord": "Մուտքագրեք բառը", "enterWords": "Մուտքագրեք բառերը", - "confirm": "Հաստատել" + "confirm": "Հաստատել", + "incorrectWord": "Սխալ բառ, խնդրում ենք ստուգել կարգը" }, "Success": { "title": "Բարի գալուստ ոչ պահառու Blink" @@ -3583,7 +3585,7 @@ "nextWords": "Հաջորդ 6 բառերը", "paste": "Փակցնել", "enterWord": "Բառ", - "invalidMnemonic": "Պահուստային արտահայտությունն անվավեր է։ Խնդրում ենք ստուգել բառերը և կրկին փորձել։", + "invalidMnemonic": "Անվավեր պահեստային արտահայտություն: Խնդրում ենք ստուգել՝ արդյոք բառերի կարգը ճիշտ է:", "restoring": "Վերականգնում ենք ձեր դրամապանակը...", "restoreSuccess": "Դրամապանակը հաջողությամբ վերականգնվեց", "restoreFailed": "Չհաջողվեց վերականգնել դրամապանակը։ Խնդրում ենք կրկին փորձել։", diff --git a/app/i18n/raw-i18n/translations/id.json b/app/i18n/raw-i18n/translations/id.json index 716832b1a5..9697b6b690 100644 --- a/app/i18n/raw-i18n/translations/id.json +++ b/app/i18n/raw-i18n/translations/id.json @@ -2329,6 +2329,7 @@ "addressScreen": "Cara menerima pembayaran", "tapUserName": "Ketuk untuk mengatur nama pengguna", "notifications": "Notifikasi", + "recoveryMethod": "Metode pemulihan", "title": "Pengaturan", "darkMode": "Mode Gelap", "setToDark": "Mode: gelap.", @@ -3470,7 +3471,7 @@ "check3": "Tidak ada yang meminta informasi ini melalui pesan atau panggilan" }, "Phrase": { - "headerTitle": "Frasa cadangan Anda", + "headerTitle": "Frasa cadangan", "sparkCompatible": "Frasa cadangan ini berfungsi di {sparkCompatibleLink} mana pun", "sparkCompatibleLink": "dompet yang kompatibel dengan Spark", "copiedToast": "Frasa cadangan disalin ke papan klip", @@ -3484,7 +3485,8 @@ "subtitle": "Pemeriksaan cepat apakah Anda menulisnya dengan benar", "enterWord": "Masukkan kata", "enterWords": "Masukkan kata-kata", - "confirm": "Konfirmasi" + "confirm": "Konfirmasi", + "incorrectWord": "Kata salah, harap periksa urutannya" }, "Success": { "title": "Selamat datang di Blink non-kustodial" @@ -3542,7 +3544,7 @@ "nextWords": "6 kata berikutnya", "paste": "Tempel", "enterWord": "Kata", - "invalidMnemonic": "Frasa cadangan tidak valid. Periksa kata-kata Anda dan coba lagi.", + "invalidMnemonic": "Frasa cadangan tidak valid. Harap periksa apakah urutan kata sudah benar.", "restoring": "Memulihkan dompet Anda...", "restoreSuccess": "Dompet berhasil dipulihkan", "restoreFailed": "Gagal memulihkan dompet. Silakan coba lagi.", diff --git a/app/i18n/raw-i18n/translations/it.json b/app/i18n/raw-i18n/translations/it.json index 8f01e6f43c..0122872293 100644 --- a/app/i18n/raw-i18n/translations/it.json +++ b/app/i18n/raw-i18n/translations/it.json @@ -2329,6 +2329,7 @@ "addressScreen": "Modi per farsi pagare", "tapUserName": "Tocca per impostare l'username", "notifications": "Notifiche", + "recoveryMethod": "Metodo di recupero", "title": "Impostazioni", "darkMode": "Modalità scura", "setToDark": "Modalità: scuro.", @@ -3458,7 +3459,7 @@ "check3": "Nessuno mi sta chiedendo queste informazioni tramite messaggio o chiamata" }, "Phrase": { - "headerTitle": "La tua frase di backup", + "headerTitle": "Frase di backup", "sparkCompatible": "Questa frase di backup funziona in qualsiasi {sparkCompatibleLink}", "sparkCompatibleLink": "portafoglio compatibile con Spark", "copiedToast": "Frase di backup copiata negli appunti", @@ -3472,7 +3473,8 @@ "subtitle": "Un controllo rapido per verificare di averla scritta correttamente", "enterWord": "Inserisci la parola", "enterWords": "Inserisci le parole", - "confirm": "Conferma" + "confirm": "Conferma", + "incorrectWord": "Parola sbagliata, controlla l'ordine" }, "Success": { "title": "Benvenuto su Blink non-custodial" @@ -3530,7 +3532,7 @@ "nextWords": "Prossime 6 parole", "paste": "Incolla", "enterWord": "Parola", - "invalidMnemonic": "Frase di backup non valida. Controlla le tue parole e riprova.", + "invalidMnemonic": "Frase di backup non valida. Verifica che l'ordine delle parole sia corretto.", "restoring": "Ripristino del portafoglio...", "restoreSuccess": "Portafoglio ripristinato con successo", "restoreFailed": "Ripristino del portafoglio fallito. Riprova.", diff --git a/app/i18n/raw-i18n/translations/ja.json b/app/i18n/raw-i18n/translations/ja.json index aaecb3fd87..520c1e4b73 100644 --- a/app/i18n/raw-i18n/translations/ja.json +++ b/app/i18n/raw-i18n/translations/ja.json @@ -2346,6 +2346,7 @@ "addressScreen": "さまざまな受金方法を見る", "tapUserName": "タップしてユーザー名を設定する", "notifications": "通知", + "recoveryMethod": "復元方法", "title": "設定", "darkMode": "ダークモード", "setToDark": "モード:ダーク", @@ -3513,7 +3514,8 @@ "subtitle": "正しく書き留めたか簡単に確認します", "enterWord": "単語を入力", "enterWords": "単語を入力", - "confirm": "確認" + "confirm": "確認", + "incorrectWord": "正しくない単語です。順番を確認してください" }, "Success": { "title": "ノンカストディアルBlinkへようこそ" @@ -3571,7 +3573,7 @@ "nextWords": "次の6語", "paste": "貼り付け", "enterWord": "単語", - "invalidMnemonic": "無効なバックアップフレーズです。単語を確認して再試行してください。", + "invalidMnemonic": "無効なバックアップフレーズです。単語の順序が正しいか確認してください。", "restoring": "ウォレットを復元中...", "restoreSuccess": "ウォレットが正常に復元されました", "restoreFailed": "ウォレットの復元に失敗しました。再試行してください。", diff --git a/app/i18n/raw-i18n/translations/lg.json b/app/i18n/raw-i18n/translations/lg.json index a5ced0a325..4df3de026a 100644 --- a/app/i18n/raw-i18n/translations/lg.json +++ b/app/i18n/raw-i18n/translations/lg.json @@ -2329,6 +2329,7 @@ "addressScreen": "Engeri y'okusasulwa", "tapUserName": "Koona okuteeka erinnya ly'omukozesa", "notifications": "Okumanyisibwa", + "recoveryMethod": "Engeri y'okuzzaawo", "title": "Ensengeka", "darkMode": "Endabika Y'ekizikiza", "setToDark": "Endabika: y'ekizikiza.", @@ -3470,7 +3471,7 @@ "check3": "Tewali muntu ansaba ebikwata ku bino ku bubaka oba ku ssimu" }, "Phrase": { - "headerTitle": "Ekigambo kyo eky'okukoppa", + "headerTitle": "Ebigambo by'enkalakkalira", "sparkCompatible": "Ekigambo kino eky'okukoppa kikola mu {sparkCompatibleLink} yonna", "sparkCompatibleLink": "nsawo eyekuvaanyizibwa ne Spark", "copiedToast": "Ekigambo eky'okukoppa kiwanduukuliddwa ku lutimbe", @@ -3484,7 +3485,8 @@ "subtitle": "Okukebera mangu oba wakiwandiika bulungi", "enterWord": "Yingiza ekigambo", "enterWords": "Yingiza ebigambo", - "confirm": "Kakasa" + "confirm": "Kakasa", + "incorrectWord": "Ekigambo si kituufu, tunuulira enkola" }, "Success": { "title": "Tukulabiriza ku Blink etali ya kuzibika" @@ -3542,7 +3544,7 @@ "nextWords": "Ebigambo 6 ebiddako", "paste": "Teeka", "enterWord": "Ekigambo", - "invalidMnemonic": "Ekigambo ky'okukuuma si kituufu. Kebera ebigambo byo oddemu.", + "invalidMnemonic": "Ebigambo by'enkalakkalira tebituufu. Kakasa nti omulongo gw'ebigambo gwa kituufu.", "restoring": "Ezzaawo wallet yo...", "restoreSuccess": "Wallet eddiziddwamu bulungi", "restoreFailed": "Okuzzaawo wallet kulemye. Gezaako nate.", diff --git a/app/i18n/raw-i18n/translations/ms.json b/app/i18n/raw-i18n/translations/ms.json index 15a11777bb..c1039df192 100644 --- a/app/i18n/raw-i18n/translations/ms.json +++ b/app/i18n/raw-i18n/translations/ms.json @@ -2346,6 +2346,7 @@ "addressScreen": "Cara untuk terima bayaran", "tapUserName": "Tekan untuk tetapkan ID pengguna", "notifications": "Notifikasi", + "recoveryMethod": "Kaedah pemulihan", "title": "Tetapan", "darkMode": "Mode Gelap", "setToDark": "Mode: gelap", @@ -3511,7 +3512,7 @@ "check3": "Tiada sesiapa meminta maklumat ini melalui mesej atau panggilan" }, "Phrase": { - "headerTitle": "Frasa sandaran anda", + "headerTitle": "Frasa sandaran", "sparkCompatible": "Frasa sandaran ini berfungsi dalam mana-mana {sparkCompatibleLink}", "sparkCompatibleLink": "dompet serasi Spark", "copiedToast": "Frasa sandaran disalin ke papan klip", @@ -3525,7 +3526,8 @@ "subtitle": "Semakan pantas sama ada anda telah menulisnya dengan betul", "enterWord": "Masukkan perkataan", "enterWords": "Masukkan perkataan", - "confirm": "Sahkan" + "confirm": "Sahkan", + "incorrectWord": "Perkataan salah, sila semak susunan" }, "Success": { "title": "Selamat datang ke Blink bukan jagaan" @@ -3583,7 +3585,7 @@ "nextWords": "6 perkataan seterusnya", "paste": "Tampal", "enterWord": "Perkataan", - "invalidMnemonic": "Frasa sandaran tidak sah. Sila semak perkataan anda dan cuba lagi.", + "invalidMnemonic": "Frasa sandaran tidak sah. Sila semak sama ada susunan perkataan adalah betul.", "restoring": "Memulihkan dompet anda...", "restoreSuccess": "Dompet berjaya dipulihkan", "restoreFailed": "Gagal memulihkan dompet. Sila cuba lagi.", diff --git a/app/i18n/raw-i18n/translations/nl.json b/app/i18n/raw-i18n/translations/nl.json index a0a18baffe..1e45dd984c 100644 --- a/app/i18n/raw-i18n/translations/nl.json +++ b/app/i18n/raw-i18n/translations/nl.json @@ -2346,6 +2346,7 @@ "addressScreen": "Manieren om betaald te krijgen", "tapUserName": "Click om gebruikersnaam in te stellen", "notifications": "Meldingen", + "recoveryMethod": "Herstelmethode", "title": "Instellingen", "darkMode": "Donkere modus", "setToDark": "Modus: donker.", @@ -3511,7 +3512,7 @@ "check3": "Niemand vraagt mij om deze informatie via bericht of telefoontje" }, "Phrase": { - "headerTitle": "Je back-upzin", + "headerTitle": "Back-upzin", "sparkCompatible": "Deze back-upzin werkt in elke {sparkCompatibleLink}", "sparkCompatibleLink": "Spark-compatibele portemonnee", "copiedToast": "Back-upzin naar klembord gekopieerd", @@ -3525,7 +3526,8 @@ "subtitle": "Een snelle controle of je het correct hebt opgeschreven", "enterWord": "Voer woord in", "enterWords": "Voer woorden in", - "confirm": "Bevestigen" + "confirm": "Bevestigen", + "incorrectWord": "Onjuist woord, controleer de volgorde" }, "Success": { "title": "Welkom bij non-custodial Blink" @@ -3583,7 +3585,7 @@ "nextWords": "Volgende 6 woorden", "paste": "Plakken", "enterWord": "Woord", - "invalidMnemonic": "Ongeldige back-upfrase. Controleer je woorden en probeer opnieuw.", + "invalidMnemonic": "Ongeldige back-upzin. Controleer of de woordvolgorde correct is.", "restoring": "Je portemonnee wordt hersteld...", "restoreSuccess": "Portemonnee succesvol hersteld", "restoreFailed": "Herstel mislukt. Probeer opnieuw.", diff --git a/app/i18n/raw-i18n/translations/pt.json b/app/i18n/raw-i18n/translations/pt.json index 717c298b42..df134d2c01 100644 --- a/app/i18n/raw-i18n/translations/pt.json +++ b/app/i18n/raw-i18n/translations/pt.json @@ -2329,6 +2329,7 @@ "addressScreen": "Formas de receber pagamentos", "tapUserName": "Toque para definir o nome de usuário", "notifications": "Notificações", + "recoveryMethod": "Método de recuperação", "title": "Configurações", "darkMode": "Modo Escuro", "setToDark": "Modo: escuro.", @@ -3458,7 +3459,7 @@ "check3": "Ninguém está me pedindo estas informações por mensagem ou ligação" }, "Phrase": { - "headerTitle": "Sua frase de backup", + "headerTitle": "Frase de backup", "sparkCompatible": "Esta frase de backup funciona em qualquer {sparkCompatibleLink}", "sparkCompatibleLink": "carteira compatível com Spark", "copiedToast": "Frase de backup copiada para a área de transferência", @@ -3472,7 +3473,8 @@ "subtitle": "Uma verificação rápida se você a escreveu corretamente", "enterWord": "Digite a palavra", "enterWords": "Digite as palavras", - "confirm": "Confirmar" + "confirm": "Confirmar", + "incorrectWord": "Palavra incorreta, verifique a ordem" }, "Success": { "title": "Bem-vindo ao Blink não-custodial" @@ -3530,7 +3532,7 @@ "nextWords": "Próximas 6 palavras", "paste": "Colar", "enterWord": "Palavra", - "invalidMnemonic": "Frase de backup inválida. Verifique suas palavras e tente novamente.", + "invalidMnemonic": "Frase de backup inválida. Verifique se a ordem das palavras está correta.", "restoring": "Restaurando sua carteira...", "restoreSuccess": "Carteira restaurada com sucesso", "restoreFailed": "Falha ao restaurar carteira. Tente novamente.", diff --git a/app/i18n/raw-i18n/translations/qu.json b/app/i18n/raw-i18n/translations/qu.json index d5f69358a6..09be0e693c 100644 --- a/app/i18n/raw-i18n/translations/qu.json +++ b/app/i18n/raw-i18n/translations/qu.json @@ -2346,6 +2346,7 @@ "addressScreen": "Kawsayniyta qillqata sutichinaqinmi kaniyta rikuyta munanman", "tapUserName": "Yachanakuyta sutichinaqinmi kaniyta ruchiykachkanki", "notifications": "Notifications", + "recoveryMethod": "Kutirichiy ñan", "title": "Rikch'a", "darkMode": "Tuta kaniy", "setToDark": "Chaynamanta kaniy: tuta.", @@ -3508,7 +3509,7 @@ "check3": "Pipas mana kay willakuykunata mañawanchu willaypi utaq waqyaypi" }, "Phrase": { - "headerTitle": "Waqaychay rimaykikunata", + "headerTitle": "Waqaychana rimay", "sparkCompatible": "Kay waqaychay rimaykunaqa {sparkCompatibleLink} llamkan", "sparkCompatibleLink": "Spark wallitakunapi", "copiedToast": "Waqaychay rimay kopiyasqa", @@ -3522,7 +3523,8 @@ "subtitle": "Usqhay qhaway allinta qillqasqaykita", "enterWord": "Rimayta yaykuchiy", "enterWords": "Rimaykunata yaykuchiy", - "confirm": "Takyachiy" + "confirm": "Takyachiy", + "incorrectWord": "Mana allin simi, kawariy patmanta" }, "Success": { "title": "Blink mana waqaychisqaman haykuy" @@ -3580,7 +3582,7 @@ "nextWords": "Hamuq 6 simikuna", "paste": "Taqay", "enterWord": "Simi", - "invalidMnemonic": "Mana allin backup frase. Simiykikunata qhaway hinaspa watiqmanta ruway.", + "invalidMnemonic": "Mana allin waqaychana rimay. Simikunaq ñiqinta kawariy.", "restoring": "Walletniykita kutichichkani...", "restoreSuccess": "Wallet allinmi kutichimusqa", "restoreFailed": "Mana atinichu walletta kutichiy. Watiqmanta ruway.", diff --git a/app/i18n/raw-i18n/translations/ro.json b/app/i18n/raw-i18n/translations/ro.json index d374e80d2d..eb48105628 100644 --- a/app/i18n/raw-i18n/translations/ro.json +++ b/app/i18n/raw-i18n/translations/ro.json @@ -2329,6 +2329,7 @@ "addressScreen": "Modalități de a fi plătit", "tapUserName": "Atingeți pentru a seta numele de utilizator", "notifications": "Notificări", + "recoveryMethod": "Metodă de recuperare", "title": "Setări", "darkMode": "Mod întunecat", "setToDark": "Mod: întunecat", @@ -3470,7 +3471,7 @@ "check3": "Nimeni nu îmi cere aceste informații prin mesaj sau apel" }, "Phrase": { - "headerTitle": "Fraza ta de backup", + "headerTitle": "Frază de backup", "sparkCompatible": "Această frază de backup funcționează în orice {sparkCompatibleLink}", "sparkCompatibleLink": "portofel compatibil Spark", "copiedToast": "Fraza de backup copiată în clipboard", @@ -3484,7 +3485,8 @@ "subtitle": "O verificare rapidă dacă ai scris-o corect", "enterWord": "Introdu cuvântul", "enterWords": "Introdu cuvintele", - "confirm": "Confirmă" + "confirm": "Confirmă", + "incorrectWord": "Cuvânt incorect, verificați ordinea" }, "Success": { "title": "Bun venit la Blink non-custodial" @@ -3542,7 +3544,7 @@ "nextWords": "Următoarele 6 cuvinte", "paste": "Lipește", "enterWord": "Cuvânt", - "invalidMnemonic": "Frază de backup invalidă. Verificați cuvintele și încercați din nou.", + "invalidMnemonic": "Frază de backup invalidă. Verificați dacă ordinea cuvintelor este corectă.", "restoring": "Se restaurează portofelul...", "restoreSuccess": "Portofel restaurat cu succes", "restoreFailed": "Restaurarea a eșuat. Încercați din nou.", diff --git a/app/i18n/raw-i18n/translations/sk.json b/app/i18n/raw-i18n/translations/sk.json index 85dc02eac1..8df7da263b 100644 --- a/app/i18n/raw-i18n/translations/sk.json +++ b/app/i18n/raw-i18n/translations/sk.json @@ -2329,6 +2329,7 @@ "addressScreen": "Spôsoby, ako dostať zaplatené", "tapUserName": "Klepnutím nastavte používateľské meno", "notifications": "Notifikácie", + "recoveryMethod": "Spôsob obnovenia", "title": "Nastavenia", "darkMode": "Tmavý režim", "setToDark": "Režim: tmavý.", @@ -3470,7 +3471,7 @@ "check3": "Nikto ma nežiada o tieto informácie prostredníctvom správy alebo hovoru" }, "Phrase": { - "headerTitle": "Vaša záložná fráza", + "headerTitle": "Zálohovacia fráza", "sparkCompatible": "Táto záložná fráza funguje v akejkoľvek {sparkCompatibleLink}", "sparkCompatibleLink": "peňaženke kompatibilnej so Spark", "copiedToast": "Záložná fráza skopírovaná do schránky", @@ -3484,7 +3485,8 @@ "subtitle": "Rýchla kontrola, či ste ju správne zapísali", "enterWord": "Zadajte slovo", "enterWords": "Zadajte slová", - "confirm": "Potvrdiť" + "confirm": "Potvrdiť", + "incorrectWord": "Nesprávne slovo, skontroluj poradie" }, "Success": { "title": "Vitajte v necustodial Blink" @@ -3542,7 +3544,7 @@ "nextWords": "Ďalších 6 slov", "paste": "Prilepiť", "enterWord": "Slovo", - "invalidMnemonic": "Neplatná zálohovacia fráza. Skontrolujte slová a skúste znova.", + "invalidMnemonic": "Neplatná zálohovacia fráza. Skontroluj, či je poradie slov správne.", "restoring": "Obnovovanie peňaženky...", "restoreSuccess": "Peňaženka úspešne obnovená", "restoreFailed": "Obnova peňaženky zlyhala. Skúste znova.", diff --git a/app/i18n/raw-i18n/translations/sr.json b/app/i18n/raw-i18n/translations/sr.json index 9f454693a7..478717d0d0 100644 --- a/app/i18n/raw-i18n/translations/sr.json +++ b/app/i18n/raw-i18n/translations/sr.json @@ -2346,6 +2346,7 @@ "addressScreen": "Начини за примање плаћања", "tapUserName": "Додирните да бисте поставили корисничко име", "notifications": "Notifications", + "recoveryMethod": "Метод опоравка", "title": "Подешавања", "darkMode": "Тамни режим", "setToDark": "Режим: таман.", @@ -3508,7 +3509,7 @@ "check3": "Нико ме не тражи ове информације путем поруке или позива" }, "Phrase": { - "headerTitle": "Ваша резервна фраза", + "headerTitle": "Резервна фраза", "sparkCompatible": "Ова резервна фраза ради у сваком {sparkCompatibleLink}", "sparkCompatibleLink": "новчанику компатибилном са Spark", "copiedToast": "Резервна фраза копирана у међуспремник", @@ -3522,7 +3523,8 @@ "subtitle": "Брза провера да ли сте је исправно записали", "enterWord": "Унесите реч", "enterWords": "Унесите речи", - "confirm": "Потврди" + "confirm": "Потврди", + "incorrectWord": "Погрешна реч, проверите редослед" }, "Success": { "title": "Добродошли у Blink без скрбништва" @@ -3580,7 +3582,7 @@ "nextWords": "Следећих 6 речи", "paste": "Налепи", "enterWord": "Реч", - "invalidMnemonic": "Неважећа безбедносна фраза. Проверите речи и покушајте поново.", + "invalidMnemonic": "Неважећа резервна фраза. Проверите да ли је редослед речи исправан.", "restoring": "Обнављање новчаника...", "restoreSuccess": "Новчаник успешно обновљен", "restoreFailed": "Обнова новчаника није успела. Покушајте поново.", diff --git a/app/i18n/raw-i18n/translations/sw.json b/app/i18n/raw-i18n/translations/sw.json index d0cc77f2a6..8552f2d4a0 100644 --- a/app/i18n/raw-i18n/translations/sw.json +++ b/app/i18n/raw-i18n/translations/sw.json @@ -2329,6 +2329,7 @@ "addressScreen": "Njia za kupokea malipo", "tapUserName": "Guza ili kuandikisha jina la mtumiaji", "notifications": "Arifa", + "recoveryMethod": "Mbinu ya kurejesha", "title": "Mipangilio", "darkMode": "Njia ya Giza", "setToDark": "Njia: giza.", @@ -3470,7 +3471,7 @@ "check3": "Hakuna mtu anayeniomba taarifa hizi kupitia ujumbe au simu" }, "Phrase": { - "headerTitle": "Maneno yako ya nakala", + "headerTitle": "Kifungu cha akiba", "sparkCompatible": "Maneno haya ya nakala yanafanya kazi katika {sparkCompatibleLink} yoyote", "sparkCompatibleLink": "pochi inayoendana na Spark", "copiedToast": "Maneno ya nakala yamenakiliwa kwenye ubao", @@ -3484,7 +3485,8 @@ "subtitle": "Ukaguzi wa haraka kama umeyaandika kwa usahihi", "enterWord": "Ingiza neno", "enterWords": "Ingiza maneno", - "confirm": "Thibitisha" + "confirm": "Thibitisha", + "incorrectWord": "Neno si sahihi, tafadhali kagua mpangilio" }, "Success": { "title": "Karibu kwa Blink isiyo na uhifadhi" @@ -3542,7 +3544,7 @@ "nextWords": "Maneno 6 yanayofuata", "paste": "Bandika", "enterWord": "Neno", - "invalidMnemonic": "Msemo wa hifadhi si sahihi. Tafadhali angalia maneno yako na jaribu tena.", + "invalidMnemonic": "Kifungu cha akiba si sahihi. Tafadhali angalia kama mpangilio wa maneno ni sahihi.", "restoring": "Kurudisha mkoba wako...", "restoreSuccess": "Mkoba umerejeshwa kwa mafanikio", "restoreFailed": "Imeshindikana kurejesha mkoba. Tafadhali jaribu tena.", diff --git a/app/i18n/raw-i18n/translations/th.json b/app/i18n/raw-i18n/translations/th.json index 48ee6c4e60..01ec68a505 100644 --- a/app/i18n/raw-i18n/translations/th.json +++ b/app/i18n/raw-i18n/translations/th.json @@ -2346,6 +2346,7 @@ "addressScreen": "Ways to get paid", "tapUserName": "กดเพื่อตั้งชื่อผู้ใช้", "notifications": "Notifications", + "recoveryMethod": "วิธีการกู้คืน", "title": "การตั้งค่า", "darkMode": "Dark Mode", "setToDark": "Mode: dark.", @@ -3508,7 +3509,7 @@ "check3": "ไม่มีใครขอข้อมูลนี้จากฉันผ่านข้อความหรือโทรศัพท์" }, "Phrase": { - "headerTitle": "วลีสำรองของคุณ", + "headerTitle": "วลีสำรอง", "sparkCompatible": "วลีสำรองนี้ใช้ได้ใน{sparkCompatibleLink}", "sparkCompatibleLink": "กระเป๋าเงินที่รองรับ Spark", "copiedToast": "คัดลอกวลีสำรองไปยังคลิปบอร์ดแล้ว", @@ -3522,7 +3523,8 @@ "subtitle": "ตรวจสอบอย่างรวดเร็วว่าคุณเขียนถูกต้อง", "enterWord": "ป้อนคำ", "enterWords": "ป้อนคำ", - "confirm": "ยืนยัน" + "confirm": "ยืนยัน", + "incorrectWord": "คำไม่ถูกต้อง โปรดตรวจสอบลำดับ" }, "Success": { "title": "ยินดีต้อนรับสู่ Blink แบบไม่มีผู้ดูแล" @@ -3580,7 +3582,7 @@ "nextWords": "6 คำถัดไป", "paste": "วาง", "enterWord": "คำ", - "invalidMnemonic": "วลีสำรองข้อมูลไม่ถูกต้อง โปรดตรวจสอบคำของคุณแล้วลองอีกครั้ง", + "invalidMnemonic": "วลีสำรองไม่ถูกต้อง โปรดตรวจสอบว่าลำดับคำถูกต้องหรือไม่", "restoring": "กำลังกู้คืนกระเป๋าเงินของคุณ...", "restoreSuccess": "กู้คืนกระเป๋าเงินสำเร็จ", "restoreFailed": "กู้คืนกระเป๋าเงินล้มเหลว โปรดลองอีกครั้ง", diff --git a/app/i18n/raw-i18n/translations/tr.json b/app/i18n/raw-i18n/translations/tr.json index 35b1c7e9c8..2bb9930dc0 100644 --- a/app/i18n/raw-i18n/translations/tr.json +++ b/app/i18n/raw-i18n/translations/tr.json @@ -2329,6 +2329,7 @@ "addressScreen": "Ödeme alma yolları", "tapUserName": "Kullanıcı adını ayarlamak için tıkla", "notifications": "Bildirimler", + "recoveryMethod": "Kurtarma yöntemi", "title": "Ayarlar", "darkMode": "Karanlık Mod", "setToDark": "Mod: karanlık.", @@ -3470,7 +3471,7 @@ "check3": "Kimse benden bu bilgileri mesaj veya arama yoluyla istemiyor" }, "Phrase": { - "headerTitle": "Yedekleme ifadeniz", + "headerTitle": "Yedekleme ifadesi", "sparkCompatible": "Bu yedekleme ifadesi herhangi bir {sparkCompatibleLink} çalışır", "sparkCompatibleLink": "Spark uyumlu cüzdanda", "copiedToast": "Yedekleme ifadesi panoya kopyalandı", @@ -3484,7 +3485,8 @@ "subtitle": "Doğru yazıp yazmadığınızın hızlı bir kontrolü", "enterWord": "Kelimeyi girin", "enterWords": "Kelimeleri girin", - "confirm": "Onayla" + "confirm": "Onayla", + "incorrectWord": "Yanlış kelime, lütfen sırayı kontrol edin" }, "Success": { "title": "Gözetimsiz Blink'e hoş geldiniz" @@ -3542,7 +3544,7 @@ "nextWords": "Sonraki 6 kelime", "paste": "Yapıştır", "enterWord": "Kelime", - "invalidMnemonic": "Geçersiz yedekleme ifadesi. Lütfen kelimelerinizi kontrol edin ve tekrar deneyin.", + "invalidMnemonic": "Geçersiz yedekleme ifadesi. Lütfen kelime sırasının doğru olup olmadığını kontrol edin.", "restoring": "Cüzdanınız geri yükleniyor...", "restoreSuccess": "Cüzdan başarıyla geri yüklendi", "restoreFailed": "Cüzdan geri yüklenemedi. Lütfen tekrar deneyin.", diff --git a/app/i18n/raw-i18n/translations/vi.json b/app/i18n/raw-i18n/translations/vi.json index 6d333500fa..62d82f45dd 100644 --- a/app/i18n/raw-i18n/translations/vi.json +++ b/app/i18n/raw-i18n/translations/vi.json @@ -2346,6 +2346,7 @@ "addressScreen": "Ways to get paid", "tapUserName": "Nhấn để đặt tên người dùng", "notifications": "Notifications", + "recoveryMethod": "Phương thức khôi phục", "title": "Cài đặt", "darkMode": "Dark Mode", "setToDark": "Mode: dark.", @@ -3508,7 +3509,7 @@ "check3": "Không ai yêu cầu tôi cung cấp thông tin này qua tin nhắn hoặc cuộc gọi" }, "Phrase": { - "headerTitle": "Cụm từ sao lưu của bạn", + "headerTitle": "Cụm từ sao lưu", "sparkCompatible": "Cụm từ sao lưu này hoạt động trong bất kỳ {sparkCompatibleLink} nào", "sparkCompatibleLink": "ví tương thích Spark", "copiedToast": "Đã sao chép cụm từ sao lưu vào bộ nhớ tạm", @@ -3522,7 +3523,8 @@ "subtitle": "Kiểm tra nhanh xem bạn đã ghi đúng chưa", "enterWord": "Nhập từ", "enterWords": "Nhập các từ", - "confirm": "Xác nhận" + "confirm": "Xác nhận", + "incorrectWord": "Sai từ, vui lòng kiểm tra thứ tự" }, "Success": { "title": "Chào mừng đến với Blink tự quản lý" @@ -3580,7 +3582,7 @@ "nextWords": "6 từ tiếp theo", "paste": "Dán", "enterWord": "Từ", - "invalidMnemonic": "Cụm từ sao lưu không hợp lệ. Vui lòng kiểm tra các từ và thử lại.", + "invalidMnemonic": "Cụm từ sao lưu không hợp lệ. Vui lòng kiểm tra xem thứ tự các từ có chính xác không.", "restoring": "Đang khôi phục ví...", "restoreSuccess": "Khôi phục ví thành công", "restoreFailed": "Khôi phục ví thất bại. Vui lòng thử lại.", diff --git a/app/i18n/raw-i18n/translations/xh.json b/app/i18n/raw-i18n/translations/xh.json index 077b71d145..47132da84b 100644 --- a/app/i18n/raw-i18n/translations/xh.json +++ b/app/i18n/raw-i18n/translations/xh.json @@ -2364,6 +2364,7 @@ "addressScreen": "Iindlela zokuhlawulwa", "tapUserName": "Cofa ukuseta igama lomsebenzisi", "notifications": "Izaziso", + "recoveryMethod": "Indlela yokubuyisela", "title": "Iisetingi", "darkMode": "Isimo Esimnyama", "setToDark": "Isimo: Esimnyama.", @@ -3517,7 +3518,7 @@ "check3": "Akukho mntu undicela olu lwazi ngomlayezo okanye umnxeba" }, "Phrase": { - "headerTitle": "Ibinzana lakho lokugcina", + "headerTitle": "Ibinzana lokugcina", "sparkCompatible": "Eli binzana lokugcina lisebenza kuyo nayiphi na {sparkCompatibleLink}", "sparkCompatibleLink": "isikhwama esihambisanayo ne-Spark", "copiedToast": "Ibinzana lokugcina likopishwe kwibhodi", @@ -3531,7 +3532,8 @@ "subtitle": "Ukuhlola ngokukhawuleza ukuba ulibhale ngokuchanekileyo", "enterWord": "Faka igama", "enterWords": "Faka amagama", - "confirm": "Qinisekisa" + "confirm": "Qinisekisa", + "incorrectWord": "Igama elingachananga, nceda ujonge ulandelelwano" }, "Success": { "title": "Wamkelekile ku-Blink engagcinwayo" @@ -3589,7 +3591,7 @@ "nextWords": "Amagama a-6 alandelayo", "paste": "Ncamathisela", "enterWord": "Igama", - "invalidMnemonic": "Umgca wokugcina ongekho semthethweni. Nceda ujonge amagama akho uze uzame kwakhona.", + "invalidMnemonic": "Ibinzana lokugcina elingachananga. Nceda ujonge ukuba ulandelelwano lwamagama lulungile.", "restoring": "Kubuyiselwa isipaji sakho...", "restoreSuccess": "Isipaji sibuyiselwe ngempumelelo", "restoreFailed": "Akuphumelelanga ukubuyisela isipaji. Nceda uzame kwakhona.", diff --git a/app/navigation/stack-param-lists.ts b/app/navigation/stack-param-lists.ts index 39c391386d..4fa26c9a31 100644 --- a/app/navigation/stack-param-lists.ts +++ b/app/navigation/stack-param-lists.ts @@ -189,7 +189,7 @@ export type RootStackParamList = { sparkBackupConfirmScreen: { challenges: Array<{ index: number; word: string }> } - sparkBackupSuccessScreen: undefined + sparkBackupSuccessScreen: { reBackup?: boolean } | undefined sparkMigrationExplainer: undefined sparkMigrationTransferringFunds: undefined sparkRestorePhraseScreen: { step: PhraseStep; words?: string[] } diff --git a/app/screens/account-type-selection/account-type-selection.tsx b/app/screens/account-type-selection/account-type-selection.tsx index 053ba21737..d91d54ada2 100644 --- a/app/screens/account-type-selection/account-type-selection.tsx +++ b/app/screens/account-type-selection/account-type-selection.tsx @@ -77,6 +77,7 @@ export const AccountTypeSelectionScreen: React.FC = () => { styles.card, isSelected(AccountOption.Custodial) && { borderColor: colors.primary, + backgroundColor: colors.grey6, }, ]} onPress={() => setSelected(AccountOption.Custodial)} @@ -98,6 +99,7 @@ export const AccountTypeSelectionScreen: React.FC = () => { styles.card, isSelected(AccountOption.SelfCustodial) && { borderColor: colors.primary, + backgroundColor: colors.grey6, }, ]} onPress={() => setSelected(AccountOption.SelfCustodial)} diff --git a/app/screens/home-screen/home-screen.tsx b/app/screens/home-screen/home-screen.tsx index 1118550d0d..768d2ec40c 100644 --- a/app/screens/home-screen/home-screen.tsx +++ b/app/screens/home-screen/home-screen.tsx @@ -641,7 +641,10 @@ export const HomeScreen: React.FC = () => { bottomOffset={15} onAction={() => navigation.navigate("transactionHistory")} /> - + ) diff --git a/app/screens/settings-screen/account/banner.tsx b/app/screens/settings-screen/account/banner.tsx index bab6287d8e..b97147fa13 100644 --- a/app/screens/settings-screen/account/banner.tsx +++ b/app/screens/settings-screen/account/banner.tsx @@ -10,6 +10,7 @@ import { TouchableWithoutFeedback } from "react-native-gesture-handler" import { GaloyIcon } from "@app/components/atomic/galoy-icon" import { useSettingsScreenQuery } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" import { AccountLevel, useLevel } from "@app/graphql/level-context" import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" @@ -31,8 +32,12 @@ export const AccountBanner: React.FC = () => { const { currentLevel } = useLevel() const isUserLoggedIn = currentLevel !== AccountLevel.NonAuth + const isAuthed = useIsAuthed() - const { data, loading } = useSettingsScreenQuery({ fetchPolicy: "cache-first" }) + const { data, loading } = useSettingsScreenQuery({ + skip: !isAuthed, + fetchPolicy: "cache-first", + }) const hasUsername = Boolean(data?.me?.username) const lnAddress = `${data?.me?.username}@${lnAddressHostname}` diff --git a/app/screens/settings-screen/account/login-methods-hook.ts b/app/screens/settings-screen/account/login-methods-hook.ts index 5627698b0f..ad69004e62 100644 --- a/app/screens/settings-screen/account/login-methods-hook.ts +++ b/app/screens/settings-screen/account/login-methods-hook.ts @@ -1,7 +1,12 @@ import { useSettingsScreenQuery } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" export const useLoginMethods = () => { - const { data } = useSettingsScreenQuery({ fetchPolicy: "cache-and-network" }) + const isAuthed = useIsAuthed() + const { data } = useSettingsScreenQuery({ + skip: !isAuthed, + fetchPolicy: "cache-and-network", + }) const email = data?.me?.email?.address || undefined const emailVerified = Boolean(email && data?.me?.email?.verified) diff --git a/app/screens/settings-screen/settings-screen.tsx b/app/screens/settings-screen/settings-screen.tsx index 7af70c42ec..43ed03fc84 100644 --- a/app/screens/settings-screen/settings-screen.tsx +++ b/app/screens/settings-screen/settings-screen.tsx @@ -13,6 +13,7 @@ import { Screen } from "@app/components/screen" import { SettingsCard } from "./settings-card" import { useI18nContext } from "@app/i18n/i18n-react" import { VersionComponent } from "@app/components/version" +import { useIsAuthed } from "@app/graphql/is-authed-context" import { useLevel } from "@app/graphql/level-context" import { RootStackParamList } from "@app/navigation/stack-param-lists" import { useUnacknowledgedNotificationCountQuery } from "@app/graphql/generated" @@ -83,9 +84,11 @@ export const SettingsScreen: React.FC = () => { const styles = useStyles() const { LL } = useI18nContext() + const isAuthed = useIsAuthed() const { isAtLeastLevelOne } = useLevel() const { shouldShowSettingsBanner } = useBackupNudgeState() const { data: unackNotificationCount } = useUnacknowledgedNotificationCountQuery({ + skip: !isAuthed, fetchPolicy: "cache-and-network", }) diff --git a/app/screens/settings-screen/settings/account-default-wallet.tsx b/app/screens/settings-screen/settings/account-default-wallet.tsx index e5e44f534b..0f4f20cfca 100644 --- a/app/screens/settings-screen/settings/account-default-wallet.tsx +++ b/app/screens/settings-screen/settings/account-default-wallet.tsx @@ -1,5 +1,6 @@ import React from "react" import { useSettingsScreenQuery } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" import { getBtcWallet } from "@app/graphql/wallets-utils" import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" @@ -11,8 +12,9 @@ import { SettingsRow } from "../row" export const DefaultWallet: React.FC = () => { const { LL } = useI18nContext() const { navigate } = useNavigation>() + const isAuthed = useIsAuthed() - const { data, loading } = useSettingsScreenQuery() + const { data, loading } = useSettingsScreenQuery({ skip: !isAuthed }) const btcWallet = getBtcWallet(data?.me?.defaultAccount?.wallets) const btcWalletId = btcWallet?.id diff --git a/app/screens/settings-screen/settings/account-ln-address.tsx b/app/screens/settings-screen/settings/account-ln-address.tsx index 996a6ebd76..fdf00bf4e5 100644 --- a/app/screens/settings-screen/settings/account-ln-address.tsx +++ b/app/screens/settings-screen/settings/account-ln-address.tsx @@ -4,6 +4,7 @@ import { useTheme } from "@rn-vui/themed" import { GaloyIcon } from "@app/components/atomic/galoy-icon" import { SetLightningAddressModal } from "@app/components/set-lightning-address-modal" import { useSettingsScreenQuery } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" import { useAppConfig, useClipboard } from "@app/hooks" import { useI18nContext } from "@app/i18n/i18n-react" @@ -19,7 +20,8 @@ export const AccountLNAddress: React.FC = () => { const [isModalShown, setModalShown] = useState(false) const toggleModalVisibility = () => setModalShown((x) => !x) - const { data, loading } = useSettingsScreenQuery() + const isAuthed = useIsAuthed() + const { data, loading } = useSettingsScreenQuery({ skip: !isAuthed }) const { LL } = useI18nContext() const { copyToClipboard } = useClipboard() diff --git a/app/screens/settings-screen/settings/account-pos.tsx b/app/screens/settings-screen/settings/account-pos.tsx index 19cf302ac0..9a0258447c 100644 --- a/app/screens/settings-screen/settings/account-pos.tsx +++ b/app/screens/settings-screen/settings/account-pos.tsx @@ -3,6 +3,7 @@ import { Linking } from "react-native" import { GaloyIcon } from "@app/components/atomic/galoy-icon" import { useSettingsScreenQuery } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" import { useAppConfig } from "@app/hooks" import { useI18nContext } from "@app/i18n/i18n-react" @@ -17,8 +18,9 @@ export const AccountPOS: React.FC = () => { const posUrl = appConfig.galoyInstance.posUrl const { LL } = useI18nContext() + const isAuthed = useIsAuthed() - const { data, loading } = useSettingsScreenQuery() + const { data, loading } = useSettingsScreenQuery({ skip: !isAuthed }) if (!data?.me?.username) return <> const pos = `${posUrl}/${data.me.username}` diff --git a/app/screens/settings-screen/settings/account-static-qr.tsx b/app/screens/settings-screen/settings/account-static-qr.tsx index 4a0736be2a..dfed074eb5 100644 --- a/app/screens/settings-screen/settings/account-static-qr.tsx +++ b/app/screens/settings-screen/settings/account-static-qr.tsx @@ -4,6 +4,7 @@ import { Linking } from "react-native" import { GaloyIcon } from "@app/components/atomic/galoy-icon" import { QrCodeIcon } from "phosphor-react-native" import { useSettingsScreenQuery } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" import { useAppConfig } from "@app/hooks" import { useI18nContext } from "@app/i18n/i18n-react" @@ -18,8 +19,9 @@ export const AccountStaticQR: React.FC = () => { const posUrl = appConfig.galoyInstance.posUrl const { LL } = useI18nContext() + const isAuthed = useIsAuthed() - const { data, loading } = useSettingsScreenQuery() + const { data, loading } = useSettingsScreenQuery({ skip: !isAuthed }) if (!data?.me?.username) return <> const qrUrl = `${posUrl}/${data.me.username}/print` diff --git a/app/screens/settings-screen/settings/advanced-export-csv.tsx b/app/screens/settings-screen/settings/advanced-export-csv.tsx index b75c36bbcd..1ad309aa5e 100644 --- a/app/screens/settings-screen/settings/advanced-export-csv.tsx +++ b/app/screens/settings-screen/settings/advanced-export-csv.tsx @@ -6,6 +6,7 @@ import { useExportCsvSettingLazyQuery, useSettingsScreenQuery, } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" import { getBtcWallet, getUsdWallet } from "@app/graphql/wallets-utils" import { useI18nContext } from "@app/i18n/i18n-react" import crashlytics from "@react-native-firebase/crashlytics" @@ -26,8 +27,9 @@ gql` export const ExportCsvSetting: React.FC = () => { const { LL } = useI18nContext() + const isAuthed = useIsAuthed() - const { data, loading } = useSettingsScreenQuery() + const { data, loading } = useSettingsScreenQuery({ skip: !isAuthed }) const btcWallet = getBtcWallet(data?.me?.defaultAccount?.wallets) const usdWallet = getUsdWallet(data?.me?.defaultAccount?.wallets) diff --git a/app/screens/settings-screen/settings/preferences-language.tsx b/app/screens/settings-screen/settings/preferences-language.tsx index 8e9f171a8e..b4da1fcb91 100644 --- a/app/screens/settings-screen/settings/preferences-language.tsx +++ b/app/screens/settings-screen/settings/preferences-language.tsx @@ -1,5 +1,6 @@ import React from "react" import { useSettingsScreenQuery } from "@app/graphql/generated" +import { useIsAuthed } from "@app/graphql/is-authed-context" import { useI18nContext } from "@app/i18n/i18n-react" import { LocaleToTranslateLanguageSelector } from "@app/i18n/mapping" import { RootStackParamList } from "@app/navigation/stack-param-lists" @@ -12,8 +13,9 @@ import { SettingsRow } from "../row" export const LanguageSetting: React.FC = () => { const { LL } = useI18nContext() const { navigate } = useNavigation>() + const isAuthed = useIsAuthed() - const { data, loading } = useSettingsScreenQuery() + const { data, loading } = useSettingsScreenQuery({ skip: !isAuthed }) const language = getLanguageFromString(data?.me?.language) const languageValue = !language || language === "DEFAULT" diff --git a/app/screens/spark-onboarding/backup-method-screen.tsx b/app/screens/spark-onboarding/backup-method-screen.tsx index 67e44247ee..abfccd37eb 100644 --- a/app/screens/spark-onboarding/backup-method-screen.tsx +++ b/app/screens/spark-onboarding/backup-method-screen.tsx @@ -69,7 +69,7 @@ export const SparkBackupMethodScreen: React.FC = () => { > diff --git a/app/screens/spark-onboarding/backup-success-screen.tsx b/app/screens/spark-onboarding/backup-success-screen.tsx index 9dc33452ec..b1b6fdb2b7 100644 --- a/app/screens/spark-onboarding/backup-success-screen.tsx +++ b/app/screens/spark-onboarding/backup-success-screen.tsx @@ -1,28 +1,41 @@ import React, { useCallback } from "react" import { makeStyles, Text } from "@rn-vui/themed" -import { CommonActions, useNavigation } from "@react-navigation/native" +import { + CommonActions, + RouteProp, + useNavigation, + useRoute, +} from "@react-navigation/native" import { Screen } from "@app/components/screen" import { SuccessScreenLayout } from "@app/components/success-screen-layout" import { useI18nContext } from "@app/i18n/i18n-react" +import { RootStackParamList } from "@app/navigation/stack-param-lists" import { useMigrationCheckpoint } from "@app/screens/account-migration/hooks" +type SuccessRouteProp = RouteProp + export const SparkBackupSuccessScreen: React.FC = () => { const { LL } = useI18nContext() const styles = useStyles() const navigation = useNavigation() const { clearCheckpoint } = useMigrationCheckpoint() + const reBackup = useRoute().params?.reBackup ?? false const navigateToHome = useCallback(() => { clearCheckpoint() navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: "Primary" }] })) }, [navigation, clearCheckpoint]) + const message = reBackup + ? LL.common.success() + : LL.BackupScreen.ManualBackup.Success.title() + return ( - {LL.BackupScreen.ManualBackup.Success.title()} + {message} ) diff --git a/app/screens/spark-onboarding/cloud-backup-screen.tsx b/app/screens/spark-onboarding/cloud-backup-screen.tsx index 7a4bbaeb33..f40861bc64 100644 --- a/app/screens/spark-onboarding/cloud-backup-screen.tsx +++ b/app/screens/spark-onboarding/cloud-backup-screen.tsx @@ -32,6 +32,8 @@ export const SparkCloudBackupScreen: React.FC = () => { toggleEncryption, setPassword, setConfirmPassword, + markPasswordTouched, + markConfirmPasswordTouched, passwordError, confirmPasswordError, isValid, @@ -46,7 +48,7 @@ export const SparkCloudBackupScreen: React.FC = () => { { label={LL.BackupScreen.CloudBackup.password()} value={password} onChangeText={setPassword} + onBlur={markPasswordTouched} placeholder={LL.BackupScreen.CloudBackup.passwordPlaceholder()} error={passwordError} {...testProps("cloud-password-input")} @@ -77,6 +80,7 @@ export const SparkCloudBackupScreen: React.FC = () => { label={LL.BackupScreen.CloudBackup.confirmPassword()} value={confirmPassword} onChangeText={setConfirmPassword} + onBlur={markConfirmPasswordTouched} placeholder={LL.BackupScreen.CloudBackup.confirmPasswordPlaceholder()} error={confirmPasswordError} {...testProps("cloud-confirm-password-input")} @@ -85,6 +89,7 @@ export const SparkCloudBackupScreen: React.FC = () => { void + disabled?: boolean } const AUTO_NAVIGATE_DELAY_MS = 400 -export const useBackupConfirm = ({ challenges, onComplete }: UseBackupConfirmParams) => { +export const useBackupConfirm = ({ + challenges, + onComplete, + disabled = false, +}: UseBackupConfirmParams) => { const [inputs, setInputs] = useState(() => challenges.map(() => "")) const [activeIndex, setActiveIndex] = useState() + const [focusRequest, setFocusRequest] = useState(null) const hasCompleted = useRef(false) - const updateInput = (index: number, value: string) => + const updateInput = (index: number, value: string) => { setInputs((prev) => prev.map((current, idx) => (idx === index ? value : current))) + const normalized = value.trim().toLowerCase() + const expected = challenges[index].word.toLowerCase() + if (normalized === expected && index < challenges.length - 1) { + setFocusRequest(index + 1) + } + } const selectSuggestion = (index: number, word: string) => { updateInput(index, word) } + const clearFocusRequest = useCallback(() => setFocusRequest(null), []) + const isWordCorrect = (index: number): boolean => inputs[index].trim().toLowerCase() === challenges[index].word.toLowerCase() @@ -38,13 +52,16 @@ export const useBackupConfirm = ({ challenges, onComplete }: UseBackupConfirmPar : getBip39Suggestions(inputs[activeIndex], { maxResults: 3 }) useEffect(() => { - if (allCorrect && !hasCompleted.current) { - hasCompleted.current = true - const timer = setTimeout(onComplete, AUTO_NAVIGATE_DELAY_MS) - return () => clearTimeout(timer) + if (disabled) return undefined + if (!allCorrect) { + hasCompleted.current = false + return undefined } - return undefined - }, [allCorrect, onComplete]) + if (hasCompleted.current) return undefined + hasCompleted.current = true + const timer = setTimeout(onComplete, AUTO_NAVIGATE_DELAY_MS) + return () => clearTimeout(timer) + }, [allCorrect, disabled, onComplete]) return { inputs, @@ -57,5 +74,7 @@ export const useBackupConfirm = ({ challenges, onComplete }: UseBackupConfirmPar selectSuggestion, isWordCorrect, isWordWrong, + focusRequest, + clearFocusRequest, } } diff --git a/app/screens/spark-onboarding/hooks/use-cloud-backup-form.ts b/app/screens/spark-onboarding/hooks/use-cloud-backup-form.ts index 251d31c0f4..228c33235b 100644 --- a/app/screens/spark-onboarding/hooks/use-cloud-backup-form.ts +++ b/app/screens/spark-onboarding/hooks/use-cloud-backup-form.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useFocusEffect } from "@react-navigation/native" @@ -10,12 +10,16 @@ export const useCloudBackupForm = () => { const [isEncrypted, setIsEncrypted] = useState(false) const [password, setPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("") + const [passwordTouched, setPasswordTouched] = useState(false) + const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false) useFocusEffect( useCallback(() => { return () => { setPassword("") setConfirmPassword("") + setPasswordTouched(false) + setConfirmPasswordTouched(false) } }, []), ) @@ -24,32 +28,51 @@ export const useCloudBackupForm = () => { setIsEncrypted((prev) => !prev) setPassword("") setConfirmPassword("") + setPasswordTouched(false) + setConfirmPasswordTouched(false) }, []) + const markPasswordTouched = useCallback(() => { + setPasswordTouched(true) + }, []) + + const markConfirmPasswordTouched = useCallback(() => { + setConfirmPasswordTouched(true) + }, []) + + useEffect(() => { + if (password.length === 0) setPasswordTouched(false) + }, [password]) + + useEffect(() => { + if (confirmPassword.length === 0) setConfirmPasswordTouched(false) + }, [confirmPassword]) + const passwordValidation = useMemo(() => { if (!isEncrypted || password.length === 0) return null return validatePassword(password) }, [isEncrypted, password]) const passwordError = useMemo(() => { - if (!passwordValidation) return undefined - if (passwordValidation.valid) return undefined - if (passwordValidation.errors.includes(PasswordIssue.TooShort)) + if (!passwordTouched || !passwordValidation || passwordValidation.valid) { + return undefined + } + const { errors } = passwordValidation + if (errors.includes(PasswordIssue.TooShort)) return LL.BackupScreen.CloudBackup.passwordTooShort() - if (passwordValidation.errors.includes(PasswordIssue.CommonPassword)) - return LL.common.passwordCommon() - if (passwordValidation.errors.includes(PasswordIssue.TooWeak)) - return LL.common.passwordTooWeak() + if (errors.includes(PasswordIssue.CommonPassword)) return LL.common.passwordCommon() + if (errors.includes(PasswordIssue.TooWeak)) return LL.common.passwordTooWeak() return undefined - }, [passwordValidation, LL]) + }, [passwordTouched, passwordValidation, LL]) const confirmPasswordError = useMemo(() => { - if (!isEncrypted) return undefined - if (confirmPassword.length === 0) return undefined + if (!confirmPasswordTouched || !isEncrypted || confirmPassword.length === 0) { + return undefined + } if (confirmPassword !== password) return LL.BackupScreen.CloudBackup.passwordMismatch() return undefined - }, [isEncrypted, confirmPassword, password, LL]) + }, [confirmPasswordTouched, isEncrypted, confirmPassword, password, LL]) const isValid = useMemo(() => { if (!isEncrypted) return true @@ -65,6 +88,8 @@ export const useCloudBackupForm = () => { toggleEncryption, setPassword, setConfirmPassword, + markPasswordTouched, + markConfirmPasswordTouched, passwordError, confirmPasswordError, isValid, diff --git a/app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx b/app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx index 05b067e7d6..cf932a81dc 100644 --- a/app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx +++ b/app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react" +import React, { useCallback, useEffect, useRef } from "react" import { TextInput, View } from "react-native" import { makeStyles, Text, useTheme } from "@rn-vui/themed" @@ -12,7 +12,11 @@ import { SuggestionBar } from "@app/components/suggestion-bar" import { useActiveWallet } from "@app/hooks/use-active-wallet" import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" -import { useBackupState } from "@app/self-custodial/providers/backup-state-provider" +import { useMigrationCheckpoint } from "@app/screens/account-migration/hooks" +import { + BackupStatus, + useBackupState, +} from "@app/self-custodial/providers/backup-state-provider" import { testProps } from "@app/utils/testProps" import { useBackupConfirm } from "../hooks" @@ -29,17 +33,20 @@ export const SparkBackupConfirmScreen: React.FC = () => { const { challenges } = useRoute().params const { wallets } = useActiveWallet() - const { setBackupCompleted } = useBackupState() + const { backupState, setBackupCompleted } = useBackupState() + const { checkpoint, loading: checkpointLoading } = useMigrationCheckpoint() + const alreadyBackedUp = backupState.status === BackupStatus.Completed + const isMigrating = checkpoint !== null && !alreadyBackedUp const hasFunds = wallets.some((w) => w.balance.amount > 0) const onComplete = useCallback(() => { setBackupCompleted("manual") - if (hasFunds) { + if (isMigrating && hasFunds) { navigation.navigate("sparkMigrationTransferringFunds") return } - navigation.navigate("sparkBackupSuccessScreen") - }, [navigation, hasFunds, setBackupCompleted]) + navigation.navigate("sparkBackupSuccessScreen", { reBackup: alreadyBackedUp }) + }, [navigation, isMigrating, hasFunds, alreadyBackedUp, setBackupCompleted]) const { inputs, @@ -52,7 +59,19 @@ export const SparkBackupConfirmScreen: React.FC = () => { selectSuggestion, isWordCorrect, isWordWrong, - } = useBackupConfirm({ challenges, onComplete }) + focusRequest, + clearFocusRequest, + } = useBackupConfirm({ challenges, onComplete, disabled: checkpointLoading }) + + const anyWrong = challenges.some((_, i) => isWordWrong(i)) + + const inputRefs = useRef>([]) + + useEffect(() => { + if (focusRequest === null) return + inputRefs.current[focusRequest]?.focus() + clearFocusRequest() + }, [focusRequest, clearFocusRequest]) return ( @@ -79,6 +98,9 @@ export const SparkBackupConfirmScreen: React.FC = () => { {challenge.index + 1}. )} { + inputRefs.current[i] = ref + }} style={styles.input} placeholder={`${LL.BackupScreen.ManualBackup.Confirm.enterWord()} ${challenge.index + 1}`} placeholderTextColor={colors.grey2} @@ -99,6 +121,17 @@ export const SparkBackupConfirmScreen: React.FC = () => { ) })} + + + {anyWrong && ( + <> + + + {LL.BackupScreen.ManualBackup.Confirm.incorrectWord()} + + + )} + {activeIndex !== undefined && ( @@ -115,7 +148,7 @@ export const SparkBackupConfirmScreen: React.FC = () => { ? LL.BackupScreen.ManualBackup.Confirm.confirm() : LL.BackupScreen.ManualBackup.Confirm.enterWords() } - disabled={!allCorrect} + disabled={!allCorrect || checkpointLoading} onPress={onComplete} {...testProps("backup-confirm-button")} /> @@ -171,6 +204,18 @@ const useStyles = makeStyles(({ colors }) => ({ color: colors.black, fontFamily: "Source Sans Pro", }, + errorContainer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + minHeight: 20, + }, + errorText: { + fontSize: 14, + lineHeight: 20, + color: colors.error, + }, buttonsContainer: { gap: 10, paddingHorizontal: 20, diff --git a/app/screens/spark-onboarding/restore/hooks/use-restore-phrase.ts b/app/screens/spark-onboarding/restore/hooks/use-restore-phrase.ts index e55a1f6714..d78fe6ab7b 100644 --- a/app/screens/spark-onboarding/restore/hooks/use-restore-phrase.ts +++ b/app/screens/spark-onboarding/restore/hooks/use-restore-phrase.ts @@ -8,6 +8,7 @@ import { validateMnemonic } from "bip39" import { useBip39Input } from "@app/hooks/use-bip39-input" import { useI18nContext } from "@app/i18n/i18n-react" import { PhraseStep, RootStackParamList } from "@app/navigation/stack-param-lists" +import { splitWords } from "@app/utils/bip39-wordlist" import { RestoreWalletStatus, useRestoreWallet } from "./use-restore-wallet" @@ -34,10 +35,27 @@ export const useRestorePhrase = ({ step, initialWords }: RestorePhraseParams) => initialWords, }) + const handlePaste = useCallback( + (text: string) => { + const accepted = bip39.handlePaste(text) + if (!accepted || !isStep1) return accepted + + const parsed = splitWords(text) + if (parsed.length === WORD_COUNT && validateMnemonic(parsed.join(" "))) { + navigation.navigate("sparkRestorePhraseScreen", { + step: PhraseStep.Second, + words: parsed, + }) + } + return accepted + }, + [bip39, isStep1, navigation], + ) + const handlePasteFromClipboard = useCallback(async () => { const text = await Clipboard.getString() - if (text) bip39.handlePaste(text) - }, [bip39]) + if (text) handlePaste(text) + }, [handlePaste]) const isValid = useMemo(() => { if (!bip39.allFilled) return false @@ -76,9 +94,11 @@ export const useRestorePhrase = ({ step, initialWords }: RestorePhraseParams) => setActiveIndex: bip39.setActiveIndex, suggestions: bip39.suggestions, selectSuggestion: bip39.selectSuggestion, - handlePaste: bip39.handlePaste, + handlePaste, stepFilled: bip39.stepFilled, allFilled: bip39.allFilled, + focusRequest: bip39.focusRequest, + clearFocusRequest: bip39.clearFocusRequest, updateWord, handlePasteFromClipboard, isValid, diff --git a/app/screens/spark-onboarding/restore/hooks/use-restore-wallet.ts b/app/screens/spark-onboarding/restore/hooks/use-restore-wallet.ts index afea673c4e..d0c520b628 100644 --- a/app/screens/spark-onboarding/restore/hooks/use-restore-wallet.ts +++ b/app/screens/spark-onboarding/restore/hooks/use-restore-wallet.ts @@ -7,7 +7,10 @@ import crashlytics from "@react-native-firebase/crashlytics" import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" import { selfCustodialRestoreWallet } from "@app/self-custodial/bridge" -import { useBackupState } from "@app/self-custodial/providers/backup-state-provider" +import { + BackupMethod, + useBackupState, +} from "@app/self-custodial/providers/backup-state-provider" import { useSelfCustodialWallet } from "@app/self-custodial/providers/wallet-provider" import { usePersistentStateContext } from "@app/store/persistent-state" import { DefaultAccountId } from "@app/types/wallet.types" @@ -33,7 +36,7 @@ export const useRestoreWallet = () => { const navigation = useNavigation>() const { updateState } = usePersistentStateContext() const { retry: reinitSdk } = useSelfCustodialWallet() - const { resetBackupState } = useBackupState() + const { setBackupCompleted } = useBackupState() const [status, setStatus] = useState(RestoreWalletStatus.Idle) const activateAccount = useCallback(() => { @@ -50,7 +53,7 @@ export const useRestoreWallet = () => { await selfCustodialRestoreWallet(mnemonic) activateAccount() reinitSdk() - resetBackupState() + setBackupCompleted(BackupMethod.Manual) navigation.navigate("sparkBackupSuccessScreen") } catch (err) { await KeyStoreWrapper.deleteMnemonic().catch(() => {}) @@ -60,7 +63,7 @@ export const useRestoreWallet = () => { throw err } }, - [activateAccount, reinitSdk, resetBackupState, navigation, LL], + [activateAccount, reinitSdk, setBackupCompleted, navigation, LL], ) return { restore, status } diff --git a/app/screens/spark-onboarding/restore/restore-method-screen.tsx b/app/screens/spark-onboarding/restore/restore-method-screen.tsx index 89973898eb..699d81483b 100644 --- a/app/screens/spark-onboarding/restore/restore-method-screen.tsx +++ b/app/screens/spark-onboarding/restore/restore-method-screen.tsx @@ -74,7 +74,7 @@ export const SparkRestoreMethodScreen: React.FC = () => { > diff --git a/app/screens/spark-onboarding/restore/restore-phrase-screen.tsx b/app/screens/spark-onboarding/restore/restore-phrase-screen.tsx index f4e4e04283..8cc8eee6e9 100644 --- a/app/screens/spark-onboarding/restore/restore-phrase-screen.tsx +++ b/app/screens/spark-onboarding/restore/restore-phrase-screen.tsx @@ -1,17 +1,21 @@ -import React, { useLayoutEffect } from "react" +import React, { useEffect, useLayoutEffect, useRef } from "react" import { ActivityIndicator, Pressable, View } from "react-native" import { RouteProp, useNavigation, useRoute } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" -import { makeStyles, Text } from "@rn-vui/themed" +import { makeStyles, Text, useTheme } from "@rn-vui/themed" +import { GaloyIcon } from "@app/components/atomic/galoy-icon" import { GaloyPrimaryButton } from "@app/components/atomic/galoy-primary-button" import { SuggestionBar } from "@app/components/suggestion-bar" import { useI18nContext } from "@app/i18n/i18n-react" import { PhraseStep, RootStackParamList } from "@app/navigation/stack-param-lists" import { testProps } from "@app/utils/testProps" -import { MnemonicWordInput } from "@app/components/mnemonic-word-input" +import { + MnemonicWordInput, + type MnemonicWordInputHandle, +} from "@app/components/mnemonic-word-input" import { OnboardingScreenLayout } from "../layouts" import { RestoreStatus, useRestorePhrase } from "./hooks/use-restore-phrase" @@ -21,6 +25,9 @@ type RestorePhraseRouteProp = RouteProp { const { LL } = useI18nContext() const styles = useStyles() + const { + theme: { colors }, + } = useTheme() const navigation = useNavigation>() const { step, words: initialWords } = useRoute().params @@ -34,14 +41,29 @@ export const SparkRestorePhraseScreen: React.FC = () => { suggestions, selectSuggestion, stepFilled, + allFilled, isValid, validationError, status, isStep1, handleContinue, handleRestore, + focusRequest, + clearFocusRequest, } = useRestorePhrase({ step, initialWords }) + const showInvalidMnemonic = !isStep1 && allFilled && !isValid + const showError = Boolean(validationError) || showInvalidMnemonic + + const inputRefs = useRef>([]) + + useEffect(() => { + if (focusRequest === null) return + const localIndex = focusRequest - offset + inputRefs.current[localIndex]?.focus() + clearFocusRequest() + }, [focusRequest, clearFocusRequest, offset]) + const pasteLabel = LL.RestoreScreen.paste() useLayoutEffect(() => { @@ -128,6 +150,9 @@ export const SparkRestorePhraseScreen: React.FC = () => { return ( { + inputRefs.current[i] = handle + }} index={globalIndex} value={word} placeholder={`${LL.RestoreScreen.enterWord()} ${globalIndex + 1}`} @@ -137,18 +162,23 @@ export const SparkRestorePhraseScreen: React.FC = () => { }} onFocus={() => setActiveIndex(globalIndex)} correct={!isStep1 && isValid} - wrong={!isStep1 && Boolean(validationError)} + wrong={showError} testID={`restore-word-${globalIndex}`} /> ) })} - {validationError && ( - - {validationError} - - )} + + {showError && ( + <> + + + {validationError ?? LL.RestoreScreen.invalidMnemonic()} + + + )} + ) } @@ -171,11 +201,19 @@ const useStyles = makeStyles(({ colors }) => ({ fontSize: 16, fontWeight: "700", }, - errorText: { - color: colors.red, - textAlign: "center", + errorContainer: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + minHeight: 20, marginTop: 12, + }, + errorText: { + color: colors.error, fontSize: 14, + lineHeight: 20, + flexShrink: 1, }, centerContainer: { flex: 1, diff --git a/app/self-custodial/adapters/payment-adapter.ts b/app/self-custodial/adapters/payment-adapter.ts index e251890a17..92df8a51e2 100644 --- a/app/self-custodial/adapters/payment-adapter.ts +++ b/app/self-custodial/adapters/payment-adapter.ts @@ -86,8 +86,8 @@ export const createGetFee = (sdk: BreezSdkInterface): GetFeeAdapter => { feeAmount: toBtcMoneyAmount(0), } } catch (err) { - crashlytics().log( - `[SparkSDK] Fee quote failed: ${err instanceof Error ? err.message : err}`, + crashlytics().recordError( + err instanceof Error ? err : new Error(`[SparkSDK] Fee quote failed: ${err}`), ) return null } diff --git a/app/self-custodial/bridge/status.ts b/app/self-custodial/bridge/status.ts index 6897379be3..d268513be8 100644 --- a/app/self-custodial/bridge/status.ts +++ b/app/self-custodial/bridge/status.ts @@ -3,4 +3,5 @@ import { type SparkStatus, } from "@breeztech/breez-sdk-spark-react-native" -export const getSparkStatus = (): Promise => breezGetSparkStatus() +export const getSparkStatus = (signal?: AbortSignal): Promise => + signal ? breezGetSparkStatus({ signal }) : breezGetSparkStatus() diff --git a/app/self-custodial/hooks/use-payment-request.ts b/app/self-custodial/hooks/use-payment-request.ts index 246ae72c1e..71d91c1423 100644 --- a/app/self-custodial/hooks/use-payment-request.ts +++ b/app/self-custodial/hooks/use-payment-request.ts @@ -5,6 +5,7 @@ import crashlytics from "@react-native-firebase/crashlytics" import { WalletCurrency } from "@app/graphql/generated" import { useActiveWallet } from "@app/hooks/use-active-wallet" import { usePriceConversion } from "@app/hooks/use-price-conversion" +import { getPaymentRequestFullUri } from "@app/screens/receive-bitcoin-screen/payment/helpers" import { Invoice, InvoiceType, @@ -19,7 +20,7 @@ import { toBtcMoneyAmount, } from "@app/types/amounts" import { toSatsAmount } from "@app/utils/amounts" -import { buildBitcoinUri, buildLightningUri } from "@app/utils/bitcoin-uri" +import { buildBitcoinUri } from "@app/utils/bitcoin-uri" import type { InvoiceData, SelfCustodialPaymentRequestState } from "./types" @@ -84,7 +85,15 @@ export const usePaymentRequest = (): SelfCustodialPaymentRequestState | null => baselinePaymentIdRef.current = lastPaymentIdRef.current setPaymentRequest(result.invoice) setRequestState(PaymentRequestState.Created) - } catch { + } catch (err) { + crashlytics().log( + `[Self-custodial] Lightning invoice generation failed (amount=${amount?.amount ?? "none"}, currency=${amount?.currencyCode ?? "none"})`, + ) + crashlytics().recordError( + err instanceof Error + ? err + : new Error(`Self-custodial invoice generation failed: ${err}`), + ) setRequestState(PaymentRequestState.Error) } }, [sdk, isReady, type, memo, amount, convertMoneyAmount]) @@ -141,7 +150,12 @@ export const usePaymentRequest = (): SelfCustodialPaymentRequestState | null => const getFullUriFn = useCallback( (params: { uppercase?: boolean; prefix?: boolean }) => { if (!paymentRequest) return "" - return buildLightningUri(paymentRequest, params.prefix) + return getPaymentRequestFullUri({ + type: Invoice.Lightning, + input: paymentRequest, + uppercase: params.uppercase, + prefix: params.prefix, + }) }, [paymentRequest], ) diff --git a/app/self-custodial/providers/is-online.ts b/app/self-custodial/providers/is-online.ts index 9049bf5875..88451a6524 100644 --- a/app/self-custodial/providers/is-online.ts +++ b/app/self-custodial/providers/is-online.ts @@ -8,6 +8,8 @@ const ONLINE_STATUSES: readonly ServiceStatus[] = [ ServiceStatus.Degraded, ] +export const STATUS_TIMEOUT_MS = 5000 + const reportSparkStatusFailure = (err: unknown): void => { recordErrorOnce( "spark-status-fetch-failed", @@ -16,12 +18,16 @@ const reportSparkStatusFailure = (err: unknown): void => { } export const getServiceStatus = async (): Promise => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), STATUS_TIMEOUT_MS) try { - const { status } = await getSparkStatus() + const { status } = await getSparkStatus(controller.signal) return status } catch (err) { reportSparkStatusFailure(err) return ServiceStatus.Major + } finally { + clearTimeout(timer) } } @@ -40,11 +46,15 @@ export const OnlineState = { export type OnlineState = (typeof OnlineState)[keyof typeof OnlineState] export const getOnlineState = async (): Promise => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), STATUS_TIMEOUT_MS) try { - const { status } = await getSparkStatus() + const { status } = await getSparkStatus(controller.signal) return isOnlineStatus(status) ? OnlineState.Online : OnlineState.Offline } catch (err) { reportSparkStatusFailure(err) return OnlineState.Unknown + } finally { + clearTimeout(timer) } } diff --git a/app/self-custodial/providers/use-sdk-lifecycle.ts b/app/self-custodial/providers/use-sdk-lifecycle.ts index 99f0f55dd0..cd11793b38 100644 --- a/app/self-custodial/providers/use-sdk-lifecycle.ts +++ b/app/self-custodial/providers/use-sdk-lifecycle.ts @@ -8,6 +8,7 @@ import { useI18nContext } from "@app/i18n/i18n-react" import { ActiveWalletStatus, type WalletState } from "@app/types/wallet.types" import KeyStoreWrapper from "@app/utils/storage/secureStorage" import { toastShow } from "@app/utils/toast" +import { withTimeout } from "@app/utils/with-timeout" import { addSdkEventListener, disconnectSdk, getUserSettings, initSdk } from "../bridge" import { logSdkEvent, SdkLogLevel } from "../logging" @@ -15,7 +16,7 @@ import { logSdkEvent, SdkLogLevel } from "../logging" import { detectBalanceStale } from "./detect-balance-stale" import { extractPaymentId, PAYMENT_RECEIVED_EVENTS, REFRESH_EVENTS } from "./sdk-events" import { validateStoredNetwork } from "./validate-network" -import { getOnlineState, OnlineState } from "./is-online" +import { getOnlineState, OnlineState, STATUS_TIMEOUT_MS } from "./is-online" import { appendTransactions, getSelfCustodialWalletSnapshot, @@ -87,35 +88,39 @@ export const useSdkLifecycle = (retryCount: number): SdkLifecycleState => { } refreshingRef.current = true + const isStale = () => sdkRef.current !== sdk + const runOnce = async () => { try { - const onlineState = await getOnlineState() - if (onlineState === OnlineState.Offline) { - setStatus((prev) => - OFFLINE_EXEMPT_STATUSES.includes(prev) ? prev : ActiveWalletStatus.Offline, - ) - return - } - if (onlineState === OnlineState.Unknown) { - crashlytics().log( - `[SparkSDK] connectivity check failed; preserving previous status`, - ) - return - } - - const snapshot = await getSelfCustodialWalletSnapshot(sdk, rawTxOffsetRef.current) + const snapshot = await withTimeout( + getSelfCustodialWalletSnapshot(sdk, rawTxOffsetRef.current), + STATUS_TIMEOUT_MS, + "wallet snapshot", + ) + if (isStale()) return setWallets(snapshot.wallets) setHasMoreTransactions(snapshot.hasMore) rawTxOffsetRef.current = snapshot.rawTransactionCount // eslint-disable-line require-atomic-updates + // Snapshot success implies network reach; we skip a second `getServiceStatus()` here. setStatus(ActiveWalletStatus.Ready) - updateBalanceStale(detectBalanceStale(snapshot.wallets)) } catch (err) { logSdkEvent(SdkLogLevel.Error, `Failed to refresh wallets: ${err}`) crashlytics().recordError( err instanceof Error ? err : new Error(`Refresh failed: ${err}`), ) + const onlineState = await getOnlineState() + if (isStale()) return setStatus((prev) => { + if (OFFLINE_EXEMPT_STATUSES.includes(prev)) return prev + if (onlineState === OnlineState.Offline) return ActiveWalletStatus.Offline + if (onlineState === OnlineState.Unknown) { + crashlytics().log( + `[SparkSDK] connectivity check failed; preserving previous status`, + ) + if (prev === ActiveWalletStatus.Loading) return ActiveWalletStatus.Error + return prev + } if (prev === ActiveWalletStatus.Ready || prev === ActiveWalletStatus.Offline) { return ActiveWalletStatus.Offline } diff --git a/app/utils/storage/secureStorage.ts b/app/utils/storage/secureStorage.ts index 027d7656b3..ccaef52d11 100644 --- a/app/utils/storage/secureStorage.ts +++ b/app/utils/storage/secureStorage.ts @@ -1,5 +1,16 @@ +import crashlytics from "@react-native-firebase/crashlytics" import RNSecureKeyStore, { ACCESSIBLE } from "react-native-secure-key-store" +const safeRemoveKey = async (key: string, label: string): Promise => { + try { + await RNSecureKeyStore.remove(key) + return true + } catch (err) { + crashlytics().recordError(err instanceof Error ? err : new Error(`${label}: ${err}`)) + return false + } +} + export default class KeyStoreWrapper { private static readonly IS_BIOMETRICS_ENABLED = "isBiometricsEnabled" private static readonly PIN = "PIN" @@ -166,13 +177,16 @@ export default class KeyStoreWrapper { } public static async deleteMnemonic(): Promise { - try { - await RNSecureKeyStore.remove(KeyStoreWrapper.MNEMONIC) - await RNSecureKeyStore.remove(KeyStoreWrapper.MNEMONIC_NETWORK).catch(() => {}) - return true - } catch { - return false - } + const primaryOk = await safeRemoveKey( + KeyStoreWrapper.MNEMONIC, + "Failed to delete MNEMONIC key", + ) + if (!primaryOk) return false + await safeRemoveKey( + KeyStoreWrapper.MNEMONIC_NETWORK, + "Failed to delete MNEMONIC_NETWORK key", + ) + return true } public static async getMnemonicNetwork(): Promise { diff --git a/app/utils/with-timeout.ts b/app/utils/with-timeout.ts new file mode 100644 index 0000000000..8bd036537e --- /dev/null +++ b/app/utils/with-timeout.ts @@ -0,0 +1,21 @@ +export const withTimeout = ( + promise: Promise, + ms: number, + label: string, +): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (err) => { + clearTimeout(timer) + reject(err) + }, + ) + })