Commit a105924
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 warnings1 parent 820ab2b commit a105924
129 files changed
Lines changed: 5021 additions & 534 deletions
File tree
- __mocks__
- __tests__
- components
- hooks
- screens
- account-type-selection
- settings-screen
- spark-onboarding
- hooks
- restore/hooks
- self-custodial/providers
- utils
- app
- components
- backup-nudge-banner
- backup-nudge-modal
- icon-hero
- info-banner
- mnemonic-word-input
- trust-model-modal
- wallet-overview
- config
- graphql
- hooks
- i18n
- en
- raw-i18n
- source
- translations
- screens
- account-type-selection
- get-started-screen
- home-screen
- settings-screen
- settings
- spark-onboarding
- hooks
- layouts
- manual-backup
- restore
- hooks
- self-custodial
- providers
- utils
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
21 | 21 | | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
22 | 44 | | |
23 | 45 | | |
24 | | - | |
25 | | - | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
26 | 64 | | |
27 | 65 | | |
28 | 66 | | |
29 | | - | |
30 | | - | |
| 67 | + | |
31 | 68 | | |
32 | 69 | | |
0 commit comments