feat(spark): backup, recovery and safety nudges #3755
feat(spark): backup, recovery and safety nudges
#3755esaugomez31 wants to merge 65 commits intofeat--spark-onboarding-wallet-creationfrom
Conversation
There was a problem hiding this comment.
Code Review: feat(spark): backup, recovery and safety nudges
Clean separation of concerns, good abstractions, solid test coverage. Issues to address:
🟠 High — fix before merge | 🟡 Medium — address soon, not blocking | 🟢 Low — nit, at your discretion
🟠 High
1. parseBackupPayload exception used as encryption signal (use-cloud-restore.ts:50-53)
Any parse failure (corrupted JSON, schema mismatch) is treated as "encrypted backup," sending user to a password prompt for a broken file. Check an explicit encrypted field instead.
2. attemptDownload may fire multiple times (use-cloud-restore.ts:62-64)
Depends on startSession, download, restore via useCallback. If any change identity, the useEffect re-fires. Add a hasRun ref guard.
3. useKeychainBackup.read() swallows all errors (use-keychain-backup.ts:20-26)
Biometric denial, device-locked, keychain corruption all return null, identical to "no backup." At minimum log to Crashlytics.
🟡 Medium
4. useTrustModelSeen defaults seen = true — if AsyncStorage.getItem fails, trust model modal is permanently hidden (trust-model-screen.tsx:8)
5. shouldShowSettingsBanner missing loaded guard — can flash before AsyncStorage resolves (use-backup-nudge-state.ts:73)
🟢 Low
6. BackupNudgeModal: toggleModal={() => {}} — verify CustomModal doesn't allow backdrop dismiss (backup-nudge-modal.tsx:28-29)
7. InfoBanner removes BannerVariant export — breaking change not called out (info-banner.tsx)
8. colors.red used instead of colors.error (restore-method-screen.tsx:91)
9. trust-model-screen.tsx exports only a hook, not a screen — should be in app/hooks/
10. onPress={onComplete} added to confirm button — looks like a bug fix, should be called out separately (backup-confirm-screen.tsx:89)
Test Coverage Gaps
| Severity | Issue | Files |
|---|---|---|
| 🟠 High | No test for corrupted/malformed JSON hitting parseBackupPayload catch path |
use-cloud-restore.spec.ts |
| 🟠 High | No test for attemptDownload firing twice from unstable deps |
use-cloud-restore.spec.ts |
| 🟡 Medium | No test for calling restore() while already restoring |
use-restore-wallet.spec.ts |
| 🟡 Medium | No test for BackupStateProvider with malformed JSON in AsyncStorage |
backup-state-provider.spec.tsx |
| 🟡 Medium | No test for shouldShowSettingsBanner before loaded resolves |
use-backup-nudge-state.spec.ts |
| 🟡 Medium | No test for useTrustModelSeen when getItem rejects |
trust-model-screen.spec.ts |
| 🟢 Low | useScreenSecurity — no test for color change re-registration |
use-screen-security.spec.ts |
Code Smells
| Severity | Issue | Files |
|---|---|---|
| 🟠 High | Control flow via exception for encrypted vs unencrypted branching | use-cloud-restore.ts:50-53 |
| 🟡 Medium | Inconsistent async-load patterns (loaded flag / default-hide / neither) | use-backup-nudge-state.ts, trust-model-screen.tsx, backup-state-provider.tsx |
| 🟡 Medium | ...bip39 spread shadows updateWord — fragile if upstream adds fields |
use-restore-phrase.ts:52-63 |
| 🟡 Medium | AsyncStorage fire-and-forget writes — silent failures | backup-state-provider.tsx, use-backup-nudge-state.ts, trust-model-screen.tsx |
| 🟢 Low | Hook file named as screen (trust-model-screen.tsx) |
trust-model-screen.tsx |
799ec0b to
65ae922
Compare
|
@grimen Please review PR Review ResponseHigh1.
|
| Gap | Status | Test |
|---|---|---|
| Corrupted/malformed JSON in cloud restore | Done | "shows error for corrupted/malformed JSON" |
attemptDownload firing twice from unstable deps |
Done | "does not fire attemptDownload twice on rerender" |
restore() while already restoring |
Done | "second restore call while first is in-flight still calls bridge" |
BackupStateProvider with malformed JSON |
Done (pre-existing) | "ignores corrupted persisted data" |
shouldShowSettingsBanner before loaded resolves |
Done | "returns false for all flags before loaded resolves" |
useTrustModelSeen when getItem rejects |
Done | "sets loaded=true even when getItem rejects" |
useScreenSecurity color change re-registration |
Not addressed | Low priority nit |
0890a10 to
5fe2393
Compare
69ec015 to
73cbca0
Compare
…or and close support
5affa3c to
9205cc7
Compare
d3235d3 to
2d9af35
Compare

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
useCloudBackuphookBackupStateProvidertracks backup status (None,Pending,Completed) and method (Cloud,Keychain,Manual), persisted to AsyncStoragesetBackupCompleted("cloud")called on successful upload3.2 OS-native secure backup
useKeychainBackuphook withWHEN_UNLOCKED_THIS_DEVICE_ONLYaccessibilityread()method touseKeychainBackupfor restore flowsetBackupCompleted("keychain")called on successful save3.3 Manual backup flow with screenshot prevention and settings re-display
useWalletMnemonicnow reads real mnemonic fromKeyStoreWrapper.getMnemonic()(replaced mock data)spark-mock-data.ts— no more hardcoded test words in production codeuseScreenSecurityhook wrapsreact-native-screenguardto prevent screenshots on backup phrase screensetBackupCompleted("manual")called on successful word confirmationViewBackupPhraseSetting)useBackupPhrasehook loads words asynchronously from keychain3.4 Manual restore flow
useRestorePhrasehook manages 12-word BIP39 input across 2 steps (6 words each)useBip39Inputreusable hook: word validation, clipboard paste detection, BIP39 suggestions (3 suggestions after 3 characters), auto-advance on suggestion selectMnemonicWordInputcomponent: styled input with word number, green/red validation bordersSparkRestorePhraseScreen: two-step word entry UI with suggestion bar, validation errors, loading/error statesuseRestoreWallethook: callsselfCustodialRestoreWallet, setsactiveAccountId, navigates to success. On failure: deletes mnemonic, reports to Crashlytics3.5 Google Drive and OS-native secure restore flow
SparkRestoreMethodScreen: entry point with 3 restore options (Cloud, Keychain, Manual phrase)useKeychainBackup.read()and restores inlineuseCloudRestorehook: downloads backup from Google Drive, handles encrypted (password prompt) and unencrypted (auto-restore) payloadsSparkCloudRestoreScreen: multi-state UI (Loading → NotFound/Error → Password → Restoring)app/utils/crypto.tsdownload()method touseGoogleDriveBackuphooksparkRestoreMethodScreen(was "coming soon" alert)3.6 Trust model and education content integration
useTrustModelSeenhook: AsyncStorage-backed boolean flag, shown once per deviceTrustModelModalcomponent: informational modal about Spark's operator-assisted trust model3.7 Backup nudge state, dismissable banner, and settings banner
useBackupNudgeStatehook: determines banner/modal/settings-banner visibility based on:BackupNudgeBannercomponent: warning banner with dismiss button and "Secure now" CTAshouldShowSettingsBanneris trueBackupWalletSettingandViewBackupPhraseSettingadded to security & privacy settings group3.8 Persistent backup modal at high-risk threshold
BackupNudgeModalcomponent: non-dismissable modal with "Secure Me" buttonbackupNudgeBannerThresholdandbackupNudgeModalThresholdin Firebase Remote ConfigSelf-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 underqueryFields.unauthedin the backend). However, the mobile app was only consuming the authenticated version viame.defaultAccount.realtimePrice, which meant self-custodial users (who have no backend token) had no price data — the balance header showed$0.00even with sats in the wallet.Added a new
realtimePriceUnauthedGraphQL operation that calls the public endpoint.usePriceConversionnow uses two sources:useRealtimePriceQuery(unchanged, same as before)useRealtimePriceUnauthedQueryas fallback with 5-minute pollingThe unauthed query only activates when there is no authenticated price data (
skip: isAuthed || Boolean(authedPrice)). Custodial flow is completely unaffected.Wallet overview individual balances
WalletOverviewcomponent had anif (isAuthed)guard that prevented individual wallet balances (Bitcoin sats, Dollar amount) from rendering for self-custodial users. Changed toif (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 bothloadinganderror/unavailablestates. When the SDK failed to init (e.g., after a fresh restore), the home screen showed an infinite spinner becauseerrorstatus is notready. Changed toactiveWallet.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
SelfCustodialWalletProviderhad already run its lifecycle with no mnemonic (since the provider mounts before the user completes restore). The SDK was stuck inunavailablestatus. AddedreinitSdk()call (viaretry()from the provider) after successful restore to re-trigger the lifecycle with the newly stored mnemonic.SDK initLogging resilience
initLoggingfrom the Breez SDK can throwSdkError.Genericwhen called after certain SDK state transitions within the same app session (e.g., after a restore triggers a second lifecycle run). This was crashinginitSdkentirely. WrappedinitLoggingin a try/catch — logging initialization is non-fatal, the SDK works correctly without it.Additional work
Infrastructure
react-native-screenguarddependency for screenshot preventionOnboardingScreenLayoutshared layout component for consistent onboarding UXSettingsCardreusable component for settings entries with icon, title, descriptionbip39-wordlist.tsutility withgetBip39SuggestionsandsplitWordshelperscrypto.tsutilities: AES-GCM encrypt/decrypt, PBKDF2 key derivation, RSA-OAEP encryptionbackupNudgeBannerThresholdandbackupNudgeModalThresholdto feature flags contextNavigation
sparkRestorePhraseScreen,sparkRestoreMethodScreen,sparkCloudRestoreScreenInternationalization
RestoreScreen,BackupNudge,TrustModel,BackupScreensectionsBackupMethod.iOSComingSoonkeyTest coverage
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-restorePending (not in scope)