Skip to content

feat(spark): backup, recovery and safety nudges #3755

Open
esaugomez31 wants to merge 65 commits intofeat--spark-onboarding-wallet-creationfrom
feat--spark-backup-and-recovery
Open

feat(spark): backup, recovery and safety nudges #3755
esaugomez31 wants to merge 65 commits intofeat--spark-onboarding-wallet-creationfrom
feat--spark-backup-and-recovery

Conversation

@esaugomez31
Copy link
Copy Markdown
Collaborator

@esaugomez31 esaugomez31 commented Apr 10, 2026

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

@esaugomez31 esaugomez31 changed the title fix: add horizontal padding to icon-hero subtitle feat(spark): backup, recovery and safety nudges Apr 10, 2026
@esaugomez31 esaugomez31 marked this pull request as ready for review April 10, 2026 04:56
@esaugomez31 esaugomez31 self-assigned this Apr 10, 2026
Copy link
Copy Markdown
Contributor

@grimen grimen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@esaugomez31 esaugomez31 force-pushed the feat--spark-backup-and-recovery branch from 799ec0b to 65ae922 Compare April 10, 2026 17:43
@esaugomez31
Copy link
Copy Markdown
Collaborator Author

@grimen Please review

PR Review Response

High

1. parseBackupPayload exception used as encryption signal

Status: Done

Replaced try/catch fallthrough with explicit isEncryptedBackup() check. Any parse failure now correctly falls through to the error handler instead of being misrouted to the password prompt.

// Before
try {
  const { mnemonic } = parseBackupPayload(result.content)
  await restore(mnemonic)
  return
} catch {
  // encrypted — needs password
}
setBackupContent(result.content)
setStep(CloudStep.Password)

// After
if (isEncryptedBackup(result.content)) {
  setBackupContent(result.content)
  setStep(CloudStep.Password)
  return
}
const { mnemonic } = parseBackupPayload(result.content)
await restore(mnemonic)

isEncryptedBackup is a standalone function in spark-backup-format.ts that safely parses JSON and checks parsed?.encrypted === true, returning false on any error.

Test: use-cloud-restore.spec.ts — "shows error for corrupted/malformed JSON"


2. attemptDownload may fire multiple times

Status: Done

Added hasRunRef guard in the useEffect that triggers attemptDownload. Prevents re-execution if callback dependencies change identity.

const hasRunRef = useRef(false)
// ...
useEffect(() => {
  if (hasRunRef.current) return
  hasRunRef.current = true
  attemptDownload()
}, [attemptDownload])

Test: use-cloud-restore.spec.ts — "does not fire attemptDownload twice on rerender"


3. useKeychainBackup.read() swallows all errors

Status: Done

Added crashlytics().recordError() in the catch block so biometric denial, device-locked, and keychain corruption are reported to telemetry while still returning null to the caller.

} catch (err) {
  crashlytics().recordError(
    err instanceof Error ? err : new Error(`Keychain read failed: ${err}`),
  )
  return null
}

Medium

4. useTrustModelSeen defaults seen = true

Status: Done

Extracted hook to app/hooks/use-trust-model-seen.ts with corrected defaults: seen = false, loaded = false. Uses .finally(() => setLoaded(true)) to guarantee loaded becomes true even if getItem rejects. Errors reported to crashlytics. trust-model-screen.tsx re-exports the hook.

Home screen gates on trustModelLoaded to prevent flash:

const showTrustModel =
  isSelfCustodial && trustModelLoaded && !trustModelSeen && satsBalance > 0

Tests:

  • "defaults to seen=false and loaded=false before loading"
  • "loads false and sets loaded when not persisted"
  • "sets loaded=true even when getItem rejects"

5. shouldShowSettingsBanner missing loaded guard

Status: Done

Added loaded to the condition:

// Before
const shouldShowSettingsBanner = !isBackedUp && isSelfCustodial

// After
const shouldShowSettingsBanner = !isBackedUp && isSelfCustodial && loaded

Test: use-backup-nudge-state.spec.ts — "returns false for all flags before loaded resolves"


Low

6. BackupNudgeModal toggleModal={() => {}}

Status: Unchanged

The reviewer said "verify CustomModal doesn't allow backdrop dismiss." The empty function is intentional to prevent dismissal. No code change needed.


7. InfoBanner removes BannerVariant export

Status: N/A

BannerVariant does not exist anywhere in the current codebase. No breaking change.


8. colors.red used instead of colors.error

Status: Done

Changed in restore-method-screen.tsx:

// Before
color: colors.red,

// After
color: colors.error,

9. trust-model-screen.tsx exports only a hook, not a screen

Status: Done

Hook logic extracted to app/hooks/use-trust-model-seen.ts. The screen file now re-exports:

export { useTrustModelSeen } from "@app/hooks/use-trust-model-seen"

10. onPress={onComplete} added to confirm button

Status: Present

onPress={onComplete} exists at line 119 of backup-confirm-screen.tsx. This was a bug fix (button was previously non-functional).


Test Coverage Gaps

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

@esaugomez31 esaugomez31 requested a review from grimen April 10, 2026 18:50
@esaugomez31 esaugomez31 force-pushed the feat--spark-backup-and-recovery branch 2 times, most recently from 0890a10 to 5fe2393 Compare April 11, 2026 01:13
grimen
grimen previously approved these changes Apr 11, 2026
grimen
grimen previously approved these changes Apr 17, 2026
@esaugomez31 esaugomez31 changed the base branch from feat--spark-onboarding-wallet-creation to graphite-base/3755 April 17, 2026 15:07
@esaugomez31 esaugomez31 dismissed grimen’s stale review April 17, 2026 15:07

The base branch was changed.

@esaugomez31 esaugomez31 force-pushed the feat--spark-backup-and-recovery branch from 69ec015 to 73cbca0 Compare April 17, 2026 15:10
@esaugomez31 esaugomez31 changed the base branch from graphite-base/3755 to feat--spark-onboarding-wallet-creation April 17, 2026 15:10
grimen
grimen previously approved these changes Apr 17, 2026
grimen
grimen previously approved these changes Apr 20, 2026
esaugomez31 added 26 commits May 5, 2026 19:59
@esaugomez31 esaugomez31 force-pushed the feat--spark-backup-and-recovery branch from 5affa3c to 9205cc7 Compare May 6, 2026 02:05
@esaugomez31 esaugomez31 force-pushed the feat--spark-onboarding-wallet-creation branch from d3235d3 to 2d9af35 Compare May 6, 2026 02:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants