Skip to content

Commit a105924

Browse files
authored
feat(spark): backup, recovery and safety nudges
(#3755) ## Summary Implements the backup, recovery, and safety nudge system for self-custodial wallets (Epic 3). Users can now secure their recovery material through multiple backup methods, restore wallets from any supported backup, and receive escalating prompts when funds are at risk without a backup. Closes blinkbitcoin/blink-wip#648 ## Epic 3 Tasks ### 3\.1 Backup method selection and Google Drive backup flow - Backup method screen presents three options: Google Drive, Password Manager (Keychain), and Manual (seed phrase) - Google Drive backup uploads encrypted/unencrypted seed phrase via `useCloudBackup` hook - `BackupStateProvider` tracks backup status (`None`, `Pending`, `Completed`) and method (`Cloud`, `Keychain`, `Manual`), persisted to AsyncStorage - `setBackupCompleted("cloud")` called on successful upload - iOS cloud backup shows "coming soon" toast (Google Drive only on Android for now) ### 3\.2 OS-native secure backup - Keychain backup via `useKeychainBackup` hook with `WHEN_UNLOCKED_THIS_DEVICE_ONLY` accessibility - Added `read()` method to `useKeychainBackup` for restore flow - `setBackupCompleted("keychain")` called on successful save - Error handling with toast on save failure ### 3\.3 Manual backup flow with screenshot prevention and settings re-display - `useWalletMnemonic` now reads real mnemonic from `KeyStoreWrapper.getMnemonic()` (replaced mock data) - Deleted `spark-mock-data.ts` — no more hardcoded test words in production code - `useScreenSecurity` hook wraps `react-native-screenguard` to prevent screenshots on backup phrase screen - `setBackupCompleted("manual")` called on successful word confirmation - Backup phrase viewable from Settings after backup is completed (`ViewBackupPhraseSetting`) - `useBackupPhrase` hook loads words asynchronously from keychain ### 3\.4 Manual restore flow - `useRestorePhrase` hook manages 12-word BIP39 input across 2 steps (6 words each) - `useBip39Input` reusable hook: word validation, clipboard paste detection, BIP39 suggestions (3 suggestions after 3 characters), auto-advance on suggestion select - `MnemonicWordInput` component: styled input with word number, green/red validation borders - `SparkRestorePhraseScreen`: two-step word entry UI with suggestion bar, validation errors, loading/error states - `useRestoreWallet` hook: calls `selfCustodialRestoreWallet`, sets `activeAccountId`, navigates to success. On failure: deletes mnemonic, reports to Crashlytics ### 3\.5 Google Drive and OS-native secure restore flow - `SparkRestoreMethodScreen`: entry point with 3 restore options (Cloud, Keychain, Manual phrase) - Keychain restore reads backup directly via `useKeychainBackup.read()` and restores inline - `useCloudRestore` hook: downloads backup from Google Drive, handles encrypted (password prompt) and unencrypted (auto-restore) payloads - `SparkCloudRestoreScreen`: multi-state UI (Loading → NotFound/Error → Password → Restoring) - AES-128-GCM decryption with PBKDF2-derived key via `app/utils/crypto.ts` - Added `download()` method to `useGoogleDriveBackup` hook - Account type selection screen now routes self-custodial restore to `sparkRestoreMethodScreen` (was "coming soon" alert) ### 3\.6 Trust model and education content integration - `useTrustModelSeen` hook: AsyncStorage-backed boolean flag, shown once per device - `TrustModelModal` component: informational modal about Spark's operator-assisted trust model - Shown on home screen for self-custodial users with balance > 0 who haven't seen it yet - "I understand" button persists seen state ### 3\.7 Backup nudge state, dismissable banner, and settings banner - `useBackupNudgeState` hook: determines banner/modal/settings-banner visibility based on: - Backup status (not completed) - Account type (self-custodial only) - BTC balance thresholds (configurable via Remote Config) - 24-hour dismissal cooldown (AsyncStorage) - `BackupNudgeBanner` component: warning banner with dismiss button and "Secure now" CTA - Shown on home screen when BTC balance >= banner threshold (default 2100 sats) - Settings screen shows orange warning banner when `shouldShowSettingsBanner` is true - `BackupWalletSetting` and `ViewBackupPhraseSetting` added to security & privacy settings group ### 3\.8 Persistent backup modal at high-risk threshold - `BackupNudgeModal` component: non-dismissable modal with "Secure Me" button - Shown on home screen when BTC balance >= modal threshold (default 21000 sats) - Cannot be closed — user must complete backup to dismiss - Thresholds configurable via `backupNudgeBannerThreshold` and `backupNudgeModalThreshold` in Firebase Remote Config ## Self-custodial balance and real-time price fix These fixes were implemented in this PR because the previous Epic 2 work used mocked data for self-custodial wallets, so the balance display issues only became visible once real wallet data was wired in Epic 3. ### Real-time price for self-custodial users The Blink backend already exposes a public `Query.realtimePrice(currency)` endpoint that does not require authentication (registered under `queryFields.unauthed` in the backend). However, the mobile app was only consuming the authenticated version via `me.defaultAccount.realtimePrice`, which meant self-custodial users (who have no backend token) had no price data — the balance header showed `$0.00` even with sats in the wallet. Added a new `realtimePriceUnauthed` GraphQL operation that calls the public endpoint. `usePriceConversion` now uses two sources: - **Authenticated users**: `useRealtimePriceQuery` (unchanged, same as before) - **Self-custodial users**: `useRealtimePriceUnauthedQuery` as fallback with 5-minute polling The unauthed query only activates when there is no authenticated price data (`skip: isAuthed || Boolean(authedPrice)`). Custodial flow is completely unaffected. ### Wallet overview individual balances `WalletOverview` component had an `if (isAuthed)` guard that prevented individual wallet balances (Bitcoin sats, Dollar amount) from rendering for self-custodial users. Changed to `if (isAuthed || hasWallets)` so that when wallet data is passed as props (from the SDK), balances render correctly regardless of auth status. ### Home screen loading state The self-custodial loading check was `!activeWallet.isReady`, which included both `loading` and `error`/`unavailable` states. When the SDK failed to init (e.g., after a fresh restore), the home screen showed an infinite spinner because `error` status is not `ready`. Changed to `activeWallet.status === "loading"` so the spinner only shows during actual SDK initialization, not on error states. ### SDK reinit after wallet restore After restoring a wallet, the `SelfCustodialWalletProvider` had already run its lifecycle with no mnemonic (since the provider mounts before the user completes restore). The SDK was stuck in `unavailable` status. Added `reinitSdk()` call (via `retry()` from the provider) after successful restore to re-trigger the lifecycle with the newly stored mnemonic. ### SDK initLogging resilience `initLogging` from the Breez SDK can throw `SdkError.Generic` when called after certain SDK state transitions within the same app session (e.g., after a restore triggers a second lifecycle run). This was crashing `initSdk` entirely. Wrapped `initLogging` in a try/catch — logging initialization is non-fatal, the SDK works correctly without it. ## Additional work ### Infrastructure - Added `react-native-screenguard` dependency for screenshot prevention - Added `OnboardingScreenLayout` shared layout component for consistent onboarding UX - Added `SettingsCard` reusable component for settings entries with icon, title, description - Added `bip39-wordlist.ts` utility with `getBip39Suggestions` and `splitWords` helpers - Added `crypto.ts` utilities: AES-GCM encrypt/decrypt, PBKDF2 key derivation, RSA-OAEP encryption - Added `backupNudgeBannerThreshold` and `backupNudgeModalThreshold` to feature flags context ### Navigation - 3 new routes: `sparkRestorePhraseScreen`, `sparkRestoreMethodScreen`, `sparkCloudRestoreScreen` - All registered in root navigator with empty title headers ### Internationalization - Added translation keys across 28 languages for: `RestoreScreen`, `BackupNudge`, `TrustModel`, `BackupScreen` sections - Updated `BackupMethod.iOSComingSoon` key ### Test coverage - 9 new test suites: `use-screen-security`, `use-wallet-mnemonic`, `use-bip39-input`, `bip39-wordlist`, `BackupStateProvider`, `MnemonicWordInput`, `BackupNudgeBanner`, `SettingsCard`, `ViewBackupPhrase`, `trust-model-screen`, `use-restore-wallet`, `use-restore-phrase`, `use-cloud-restore` - 15 existing test suites updated for new mocks, async mnemonic loading, and backup state integration - All 272 suites / 2882 tests passing ## Pending (not in scope) - iCloud backup (iOS) — Google Drive only for now - Backup verification re-prompt after restore - Backup age expiration warnings
1 parent 820ab2b commit a105924

129 files changed

Lines changed: 5021 additions & 534 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports = {
2+
initSettings: jest.fn().mockResolvedValue(undefined),
3+
register: jest.fn().mockResolvedValue(undefined),
4+
unregister: jest.fn().mockResolvedValue(undefined),
5+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React from "react"
2+
import { render, fireEvent } from "@testing-library/react-native"
3+
4+
import { BackupNudgeBanner } from "@app/components/backup-nudge-banner"
5+
6+
const mockNavigate = jest.fn()
7+
8+
jest.mock("@react-navigation/native", () => ({
9+
...jest.requireActual("@react-navigation/native"),
10+
useNavigation: () => ({ navigate: mockNavigate }),
11+
}))
12+
13+
jest.mock("@rn-vui/themed", () => {
14+
const colors = {
15+
grey2: "#999",
16+
grey5: "#f5f5f5",
17+
primary: "#007",
18+
black: "#000",
19+
white: "#fff",
20+
}
21+
return {
22+
makeStyles:
23+
(fn: (theme: { colors: typeof colors }) => Record<string, object>) => () =>
24+
fn({ colors }),
25+
Text: ({ children, ...props }: { children: React.ReactNode }) =>
26+
React.createElement("Text", props, children),
27+
useTheme: () => ({ theme: { colors } }),
28+
}
29+
})
30+
31+
jest.mock("@app/components/atomic/galoy-icon", () => ({
32+
GaloyIcon: () => null,
33+
}))
34+
35+
jest.mock("@app/components/atomic/galoy-icon-button", () => ({
36+
GaloyIconButton: ({ onPress }: { onPress: () => void }) =>
37+
React.createElement("Pressable", { onPress, testID: "dismiss-button" }),
38+
}))
39+
40+
jest.mock("@app/components/atomic/galoy-primary-button", () => ({
41+
GaloyPrimaryButton: ({ onPress, title }: { onPress: () => void; title: string }) =>
42+
React.createElement(
43+
"Pressable",
44+
{ onPress, testID: "cta-button" },
45+
React.createElement("Text", {}, title),
46+
),
47+
}))
48+
49+
jest.mock("@app/utils/testProps", () => ({
50+
testProps: (id: string) => ({ testID: id }),
51+
}))
52+
53+
jest.mock("@app/i18n/i18n-react", () => ({
54+
useI18nContext: () => ({
55+
LL: {
56+
BackupNudge: {
57+
title: () => "Your funds are at risk",
58+
description: () => "Secure your wallet now. It only takes a minute.",
59+
cta: () => "Secure wallet",
60+
},
61+
},
62+
}),
63+
}))
64+
65+
describe("BackupNudgeBanner", () => {
66+
beforeEach(() => {
67+
jest.clearAllMocks()
68+
})
69+
70+
it("renders title and description", () => {
71+
const { getByText } = render(<BackupNudgeBanner onDismiss={jest.fn()} />)
72+
73+
expect(getByText("Your funds are at risk")).toBeTruthy()
74+
expect(getByText("Secure your wallet now. It only takes a minute.")).toBeTruthy()
75+
})
76+
77+
it("renders CTA button with correct label", () => {
78+
const { getByText } = render(<BackupNudgeBanner onDismiss={jest.fn()} />)
79+
80+
expect(getByText("Secure wallet")).toBeTruthy()
81+
})
82+
83+
it("calls onDismiss when dismiss button pressed", () => {
84+
const onDismiss = jest.fn()
85+
const { getByTestId } = render(<BackupNudgeBanner onDismiss={onDismiss} />)
86+
87+
fireEvent.press(getByTestId("dismiss-button"))
88+
89+
expect(onDismiss).toHaveBeenCalledTimes(1)
90+
})
91+
92+
it("navigates to backup method screen on CTA press", () => {
93+
const { getByTestId } = render(<BackupNudgeBanner onDismiss={jest.fn()} />)
94+
95+
fireEvent.press(getByTestId("cta-button"))
96+
97+
expect(mockNavigate).toHaveBeenCalledWith("sparkBackupMethodScreen")
98+
})
99+
})
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import React from "react"
2+
import { render, fireEvent } from "@testing-library/react-native"
3+
4+
import { BackupNudgeModal } from "@app/components/backup-nudge-modal"
5+
6+
const mockNavigate = jest.fn()
7+
8+
jest.mock("@react-navigation/native", () => ({
9+
...jest.requireActual("@react-navigation/native"),
10+
useNavigation: () => ({ navigate: mockNavigate }),
11+
}))
12+
13+
jest.mock("@rn-vui/themed", () => {
14+
const colors = {
15+
grey0: "#ccc",
16+
grey2: "#999",
17+
grey5: "#f5f5f5",
18+
primary: "#fc5805",
19+
black: "#000",
20+
white: "#fff",
21+
}
22+
return {
23+
makeStyles:
24+
(fn: (...args: unknown[]) => Record<string, object>) => (props?: unknown) =>
25+
fn({ colors }, props ?? {}),
26+
Text: ({ children, ...props }: { children: React.ReactNode }) =>
27+
React.createElement("Text", props, children),
28+
useTheme: () => ({ theme: { colors, mode: "light" } }),
29+
}
30+
})
31+
32+
const mockGaloyIcon = jest.fn<null, [Record<string, unknown>]>(() => null)
33+
34+
jest.mock("@app/components/atomic/galoy-icon", () => ({
35+
GaloyIcon: (props: Record<string, unknown>) => {
36+
mockGaloyIcon(props)
37+
return null
38+
},
39+
}))
40+
41+
jest.mock("@app/components/atomic/galoy-icon-button", () => ({
42+
GaloyIconButton: ({ onPress }: { onPress: () => void }) =>
43+
React.createElement("Pressable", { onPress, testID: "close-button" }),
44+
}))
45+
46+
jest.mock("@app/components/atomic/galoy-primary-button", () => ({
47+
GaloyPrimaryButton: ({ onPress, title }: { onPress: () => void; title: string }) =>
48+
React.createElement(
49+
"Pressable",
50+
{ onPress, testID: "secure-button" },
51+
React.createElement("Text", {}, title),
52+
),
53+
}))
54+
55+
jest.mock("@app/components/atomic/galoy-secondary-button", () => ({
56+
GaloySecondaryButton: () => null,
57+
}))
58+
59+
jest.mock("react-native-modal", () => {
60+
const MockModal = ({
61+
children,
62+
isVisible,
63+
}: {
64+
children: React.ReactNode
65+
isVisible: boolean
66+
}) => (isVisible ? React.createElement("View", { testID: "modal" }, children) : null)
67+
MockModal.displayName = "MockModal"
68+
return MockModal
69+
})
70+
71+
jest.mock("@app/utils/testProps", () => ({
72+
testProps: (id: string) => ({ testID: id }),
73+
}))
74+
75+
jest.mock("@app/i18n/i18n-react", () => ({
76+
useI18nContext: () => ({
77+
LL: {
78+
BackupNudge: {
79+
modalTitle: () => "Secure your funds",
80+
modalDescription: () =>
81+
"We highly recommend you backup your wallet to prevent a complete loss of funds in case you lose this device.",
82+
secureMe: () => "Secure wallet",
83+
},
84+
},
85+
}),
86+
}))
87+
88+
describe("BackupNudgeModal", () => {
89+
beforeEach(() => {
90+
jest.clearAllMocks()
91+
})
92+
93+
it("renders when visible", () => {
94+
const { getByTestId } = render(
95+
<BackupNudgeModal isVisible={true} onClose={jest.fn()} />,
96+
)
97+
98+
expect(getByTestId("modal")).toBeTruthy()
99+
})
100+
101+
it("does not render when not visible", () => {
102+
const { queryByTestId } = render(
103+
<BackupNudgeModal isVisible={false} onClose={jest.fn()} />,
104+
)
105+
106+
expect(queryByTestId("modal")).toBeNull()
107+
})
108+
109+
it("renders title and description", () => {
110+
const { getByText } = render(
111+
<BackupNudgeModal isVisible={true} onClose={jest.fn()} />,
112+
)
113+
114+
expect(getByText("Secure your funds")).toBeTruthy()
115+
expect(
116+
getByText(
117+
"We highly recommend you backup your wallet to prevent a complete loss of funds in case you lose this device.",
118+
),
119+
).toBeTruthy()
120+
})
121+
122+
it("renders secure wallet button", () => {
123+
const { getByText } = render(
124+
<BackupNudgeModal isVisible={true} onClose={jest.fn()} />,
125+
)
126+
127+
expect(getByText("Secure wallet")).toBeTruthy()
128+
})
129+
130+
it("navigates to backup method screen and closes on button press", () => {
131+
const onClose = jest.fn()
132+
const { getByTestId } = render(
133+
<BackupNudgeModal isVisible={true} onClose={onClose} />,
134+
)
135+
136+
fireEvent.press(getByTestId("secure-button"))
137+
138+
expect(onClose).toHaveBeenCalledTimes(1)
139+
expect(mockNavigate).toHaveBeenCalledWith("sparkBackupMethodScreen")
140+
})
141+
142+
it("uses primary color for warning icon", () => {
143+
render(<BackupNudgeModal isVisible={true} onClose={jest.fn()} />)
144+
145+
expect(mockGaloyIcon).toHaveBeenCalledWith(
146+
expect.objectContaining({
147+
name: "warning",
148+
size: 52,
149+
color: "#fc5805",
150+
}),
151+
)
152+
})
153+
})

__tests__/components/info-banner.spec.tsx

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,53 @@ describe("InfoBanner", () => {
1717
expect(getByText("Banner content")).toBeTruthy()
1818
})
1919

20-
it("renders multiple children", () => {
20+
it("renders title when provided", () => {
2121
const { getByText } = render(
22+
<ContextForScreen>
23+
<InfoBanner title="Warning title">
24+
<Text>Content</Text>
25+
</InfoBanner>
26+
</ContextForScreen>,
27+
)
28+
expect(getByText("Warning title")).toBeTruthy()
29+
})
30+
31+
it("renders icon when provided", () => {
32+
const { getByText } = render(
33+
<ContextForScreen>
34+
<InfoBanner icon="warning" title="With icon">
35+
<Text>Content</Text>
36+
</InfoBanner>
37+
</ContextForScreen>,
38+
)
39+
expect(getByText("With icon")).toBeTruthy()
40+
})
41+
42+
it("renders without title and icon", () => {
43+
const { getByText, queryByText } = render(
2244
<ContextForScreen>
2345
<InfoBanner>
24-
<Text>First part</Text>
25-
<Text>Second part</Text>
46+
<Text>Only content</Text>
47+
</InfoBanner>
48+
</ContextForScreen>,
49+
)
50+
expect(getByText("Only content")).toBeTruthy()
51+
expect(queryByText("Warning title")).toBeNull()
52+
})
53+
54+
it("accepts custom iconColor and titleColor", () => {
55+
const { getByText } = render(
56+
<ContextForScreen>
57+
<InfoBanner
58+
title="Custom colors"
59+
icon="warning"
60+
iconColor="error"
61+
titleColor="error"
62+
>
63+
<Text>Content</Text>
2664
</InfoBanner>
2765
</ContextForScreen>,
2866
)
29-
expect(getByText("First part")).toBeTruthy()
30-
expect(getByText("Second part")).toBeTruthy()
67+
expect(getByText("Custom colors")).toBeTruthy()
3168
})
3269
})

0 commit comments

Comments
 (0)