Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ graph TB
BACKUPS --> WALLET_BACKUP
BACKUPS --> WALLETS
BTC_PRICE --> SETTINGS
ONBOARDING --> BACKUPS
BTCPAY --> BIP85_REGISTRY
BTCPAY --> DETERMINISTIC_WALLETS
BTCPAY --> KEYCHAIN_MANIFEST
Expand Down
120 changes: 120 additions & 0 deletions docs/backup-health-reminders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Backup health reminders

Every backup surface answers one question: **if this phone vanished right now, could you get your money back?** Each state has exactly one honest answer and exactly one thing to do about it.

| Situation | Honest answer | The one action |
| --- | --- | --- |
| Nothing backed up | No — the money is gone | Back up, urgently |
| Encrypted Vault only | Probably — it needs the server up and your PIN | Add a physical backup |
| Physical backup done (with or without a vault) | Yes — if the words are still findable and correct | Occasionally confirm that is still true |

## One surface per state

Three surfaces, three tenses, no overlap:

- the **every-launch warning** (`backup_warning_overlay.dart`) means "you are unprotected right now". Zero-backup only, unchanged by this feature.
- the **reminder popup** means "it has been a while, or something changed". Every posture except zero-backup.
- the **Backup Settings hero** means "here is your standing situation". Always available on the screen you visit deliberately.

So zero backup gets the warning and the hero, never the popup; every other state gets the popup and the hero, never the warning. One popup asks one question and offers one action.

## Reminder matrix

The reminder is evaluated for the default mainnet hot-wallet seed once at least one backup method has been verified.

| Verified backup posture | Reminder | Action |
| --- | --- | --- |
| No verified backup | None. The existing no-backup warning owns this state. | — |
| Encrypted Vault only | Every 90 days: without a physical backup you cannot recover if the vault server is unavailable. | Add a physical backup |
| Physical backup, with or without a vault | Every 365 days since the physical backup was last tested. | Test the backup |

There is deliberately **no vault-freshness or PIN reminder**. A vault-only wallet's real exposure is depending on someone else for its only recovery path, and the fix for that is a physical backup, not a PIN rehearsal. Once a physical backup exists, PIN rot cannot cost the user their money.

## Timing

A scheduled reminder is anchored on the clock of the thing being urged:

- vault only — the latest vault backup, or the last acknowledgement;
- physical backup present — the latest *physical* backup test, or the last acknowledgement. A fresh vault does not buy silence about words that were last read two years ago.

A verified backup with no completion timestamp is due immediately.

Dismissing a reminder snoozes it for a full cycle (90 or 365 days). It never writes a tested timestamp, so the Backup Settings screen keeps saying how long ago the backup was really tested while the popup is quiet. Only completing a verification flow resets that date; creating a backup counts as tested on day zero, because the creation flow tests it.

The popup is shown on the wallet home screen only, and only once the higher-priority no-backup and legacy-storage warnings are clear. A deep link or payment intent lands on its own route, so nothing ever stands between a payment and its completion.

## Balance milestone

The first time an evaluation observes an eligible balance of **10,000,000 sats or more**, the reminder is shown once, whatever the schedule says, with the posture-appropriate ask. The comparison is inclusive: exactly 10,000,000 sats counts.

That notice is retired for the lifetime of the wallet as soon as the user either dismisses it or acts on it. A balance later dropping below the threshold and crossing it again changes nothing — a wallet is told once.

## Eligible wallets and balances

Only mainnet wallets whose keys are held on the device participate in reminder evaluation and in the balance total.

Included:

- default Bitcoin and Liquid hot-wallet balances;
- imported hot wallets that sign locally; and
- the Ark balance, which is mainnet-only in the app.

Excluded:

- every testnet wallet and testnet backup record;
- watch-only wallets;
- watch-signer wallets; and
- hardware wallets.

## Backup completion timestamps

Backup timestamps represent successful completion, not the start of a flow.

Physical backup verification records one completion time after the mnemonic has been entered in the correct order and the updated backup state has been persisted. Wallets in the active environment that share the verified master fingerprint receive the same completion time.

Recoverbull records completion only after both the encrypted vault file and its server-held recovery key have been stored successfully. The completion update targets the exact wallet used to create the vault, preventing a testnet backup from changing mainnet backup state.

## Evaluation and persistence

The overlay evaluates when wallet data or the Ark balance changes, after its first frame, and when the app resumes. An empty or ineligible wallet list hides any previously visible reminder.

Reminder state is stored locally in a versioned SharedPreferences record keyed by master fingerprint. It contains only:

- the last acknowledgement time; and
- whether the balance milestone has been shown.

No mnemonic, seed, private key, vault key, or other secret is stored or logged by the reminder. Keys written by an earlier shape of the record are ignored rather than rejected. Malformed, unsupported, or unreadable records are replaced with an empty in-memory record, which favors showing another reminder over suppressing one indefinitely. The underlying read failure is logged without exposing it to the user.

If a reminder action cannot be persisted, the overlay stays visible so the user can retry. A localized "close for now" action is then available as a session-only escape; it does not acknowledge the reminder, so the app evaluates it again on the next launch.

## Backup Settings screen

The screen is composed top to bottom as status rows, then at most one hero, then the settings menu:

1. **Status rows** — Physical Backup and Encrypted Vault, each Tested or Not tested, with a muted "Last tested …" line under a tested physical backup.
2. **Hero** — the single most useful action right now, or nothing at all: an urgent *Back up your wallet* card when nothing is backed up, *add a physical backup* for a vault-only wallet, *test your backup* once the physical test is over a year old, and no card when the physical backup is fresh. The hero derives its posture from the same domain code the reminder uses, so the two can never disagree.
3. **Menu** — Encrypted vault settings, Labels, Transaction History, plus the vault-key and test-backup entries when they apply. There is no "Start Backup" row: the zero-backup hero is the way in.

## Architecture

The feature follows the repository flow described in `ARCHITECTURE.md`:

```text
overlay -> cubit -> use cases -> reminder repository -> SharedPreferences
```

The repository boundary uses a domain entity and a separate persistence model. Recoverable errors cross the UI boundary as typed failures and are translated in the presentation layer. Cross-feature calls use the public test-wallet-backup facade and route contract.

## Tests

The focused suite covers the posture matrix, both cadences and their exact boundaries, the anchor rule (a fresh vault must not silence a stale physical backup), the inclusive milestone threshold and its once-per-wallet guarantee, dismissal snoozing without recording a test, testnet and non-local-signer exclusions, persistence corruption, and each hero the screen can render.

Run it with:

```sh
fvm flutter test test/features/backup_settings \
test/features/onboarding/domain/complete_physical_backup_verification_usecase_test.dart \
test/features/test_wallet_backup/domain/complete_physical_backup_verification_usecase_test.dart \
test/features/recoverbull/domain/complete_encrypted_vault_backup_usecase_test.dart \
test/features/recoverbull/recoverbull_bloc_test.dart
```
11 changes: 0 additions & 11 deletions lib/core/recoverbull/domain/usecases/allow_permission_usecase.dart

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ class CreateEncryptedVaultUsecase {
// repo. The local try/catch is the boundary for the wallet/seed calls; the
// recoverbull repo already returns a Result that we forward.
Future<
Result<({EncryptedVault vault, String vaultKey}), RecoverBullCoreFailure>
Result<
({EncryptedVault vault, String vaultKey, String walletId}),
RecoverBullCoreFailure
>
>
execute() async {
try {
Expand All @@ -44,10 +47,6 @@ class CreateEncryptedVaultUsecase {

// The default wallet is used to derive the backup key
final defaultWallet = defaultBitcoinWallets.first;
await _walletRepository.updateEncryptedBackupTime(
time: DateTime.now(),
walletId: defaultWallet.id,
);
final defaultSeed = await _seedRepository.get(
defaultWallet.masterFingerprint,
);
Expand Down Expand Up @@ -90,7 +89,10 @@ class CreateEncryptedVaultUsecase {
plaintext: plaintext,
derivationPath: derivationPath,
)
.map((vault) => (vault: vault, vaultKey: backupKey));
.map(
(vault) =>
(vault: vault, vaultKey: backupKey, walletId: defaultWallet.id),
);
} catch (e, st) {
log.severe(message: 'createEncryptedVault failed', error: e, trace: st);
return Err(RecoverBullUnexpectedCoreFailure(e.toString()));
Expand Down
11 changes: 0 additions & 11 deletions lib/core/recoverbull/domain/usecases/fetch_permission_usecase.dart

This file was deleted.

16 changes: 13 additions & 3 deletions lib/core/widgets/cards/backup_option_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ class BackupOptionCard extends StatelessWidget {
final Widget icon;
final String title;
final String description;
final String? tag;

/// Zero or more short labels shown under the description. A card may carry
/// several (e.g. how easy an option is AND how it reaches the network).
final List<String> tags;
final VoidCallback onTap;

const BackupOptionCard({
super.key,
required this.icon,
required this.title,
required this.description,
this.tag,
this.tags = const [],
required this.onTap,
});

Expand Down Expand Up @@ -60,7 +63,14 @@ class BackupOptionCard extends StatelessWidget {
maxLines: 3,
),
const Gap(10),
if (tag != null) OptionsTag(text: tag!),
if (tags.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in tags) OptionsTag(text: tag),
],
),
],
),
),
Expand Down
46 changes: 41 additions & 5 deletions lib/features/backup_settings/backup_settings_locator.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
import 'package:bb_mobile/core/settings/domain/repositories/settings_repository.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/get_wallets_usecase.dart';
import 'package:bb_mobile/features/backup_settings/data/shared_preferences_backup_health_reminder_repository.dart';
import 'package:bb_mobile/features/backup_settings/domain/repositories/backup_health_reminder_repository.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/acknowledge_backup_health_reminder_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/backup_wallet_now_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/delete_wallet_backup_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/evaluate_backup_health_reminder_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/get_last_wallet_backup_recovery_outcome_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/load_backup_settings_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/retry_wallet_backup_recovery_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/set_wallet_backup_enabled_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/start_backup_health_action_usecase.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/watch_wallet_backup_usecase.dart';
import 'package:bb_mobile/features/backup_settings/presentation/cubit/backup_health_reminder_cubit.dart';
import 'package:bb_mobile/features/backup_settings/presentation/cubit/backup_settings_cubit.dart';
import 'package:bb_mobile/features/backup_settings/presentation/cubit/wallet_backup_settings_cubit.dart';
import 'package:bb_mobile/features/remote_keychain_recovery/public/get_last_remote_recovery_outcome_usecase.dart';
import 'package:bb_mobile/features/remote_keychain_recovery/public/remote_keychain_recovery_facade.dart';
import 'package:bb_mobile/features/wallet_backup/public/wallet_backup_facade.dart';
import 'package:bb_mobile/features/backup_settings/domain/usecases/load_backup_settings_usecase.dart';
import 'package:bb_mobile/features/backup_settings/presentation/cubit/backup_settings_cubit.dart';
import 'package:get_it/get_it.dart';

class BackupSettingsLocator {
static void setup(GetIt locator) {
locator.registerLazySingleton<BackupHealthReminderRepository>(
SharedPreferencesBackupHealthReminderRepository.new,
);
locator.registerFactory<EvaluateBackupHealthReminderUsecase>(
() => EvaluateBackupHealthReminderUsecase(
locator<BackupHealthReminderRepository>(),
),
);
locator.registerFactory<AcknowledgeBackupHealthReminderUsecase>(
() => AcknowledgeBackupHealthReminderUsecase(
locator<BackupHealthReminderRepository>(),
),
);
locator.registerFactory<StartBackupHealthActionUsecase>(
() => StartBackupHealthActionUsecase(
locator<BackupHealthReminderRepository>(),
),
);
locator.registerFactory<LoadBackupSettingsUsecase>(
() => LoadBackupSettingsUsecase(
locator<GetWalletsUsecase>(),
Expand All @@ -26,6 +50,13 @@ class BackupSettingsLocator {
loadSettings: locator<LoadBackupSettingsUsecase>(),
),
);
locator.registerFactory<BackupHealthReminderCubit>(
() => BackupHealthReminderCubit(
locator<EvaluateBackupHealthReminderUsecase>(),
locator<AcknowledgeBackupHealthReminderUsecase>(),
locator<StartBackupHealthActionUsecase>(),
),
);
locator.registerFactory<WatchWalletBackupUsecase>(
() => WatchWalletBackupUsecase(locator<WalletBackupFacade>()),
);
Expand All @@ -43,14 +74,19 @@ class BackupSettingsLocator {
locator<RemoteKeychainRecoveryFacade>(),
),
);
locator.registerFactory<GetLastWalletBackupRecoveryOutcomeUsecase>(
() => GetLastWalletBackupRecoveryOutcomeUsecase(
locator<RemoteKeychainRecoveryFacade>(),
),
);
locator.registerFactory<WalletBackupSettingsCubit>(
() => WalletBackupSettingsCubit(
locator<WatchWalletBackupUsecase>(),
locator<SetWalletBackupEnabledUsecase>(),
locator<BackupWalletNowUsecase>(),
locator<DeleteWalletBackupUsecase>(),
locator<GetLastRemoteRecoveryOutcomeUsecase>(),
locator<RetryWalletBackupRecoveryUsecase>(),
locator<GetLastWalletBackupRecoveryOutcomeUsecase>().execute,
locator<RetryWalletBackupRecoveryUsecase>().execute,
),
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import 'package:bb_mobile/features/backup_settings/domain/backup_health_reminder.dart';

class BackupHealthReminderModel {
final int version;
final int? lastAcknowledgedAtMillis;
final bool crossedTenMillionSats;

const BackupHealthReminderModel({
required this.version,
required this.lastAcknowledgedAtMillis,
required this.crossedTenMillionSats,
});

/// Reads only the keys this record still has. Keys written by an earlier
/// shape of the feature are ignored rather than rejected.
factory BackupHealthReminderModel.fromJson(Map<String, dynamic> json) =>
BackupHealthReminderModel(
version: _requiredInt(json, 'version'),
lastAcknowledgedAtMillis: _optionalInt(json, 'lastAcknowledgedAt'),
crossedTenMillionSats: _optionalBool(json, 'crossedTenMillionSats'),
);

factory BackupHealthReminderModel.fromEntity(
BackupHealthReminderRecord record, {
required int version,
}) => BackupHealthReminderModel(
version: version,
lastAcknowledgedAtMillis: record.lastAcknowledgedAt
?.toUtc()
.millisecondsSinceEpoch,
crossedTenMillionSats: record.crossedTenMillionSats,
);

Map<String, dynamic> toJson() => {
'version': version,
'lastAcknowledgedAt': lastAcknowledgedAtMillis,
'crossedTenMillionSats': crossedTenMillionSats,
};

BackupHealthReminderRecord toEntity({required String masterFingerprint}) =>
BackupHealthReminderRecord(
masterFingerprint: masterFingerprint,
lastAcknowledgedAt: _dateTimeFromMillis(lastAcknowledgedAtMillis),
crossedTenMillionSats: crossedTenMillionSats,
);

DateTime? _dateTimeFromMillis(int? milliseconds) => milliseconds == null
? null
: DateTime.fromMillisecondsSinceEpoch(milliseconds, isUtc: true);

static int _requiredInt(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is int) return value;
throw FormatException('Invalid $key');
}

static int? _optionalInt(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null || value is int) return value as int?;
throw FormatException('Invalid $key');
}

static bool _optionalBool(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null) return false;
if (value is bool) return value;
throw FormatException('Invalid $key');
}
}
Loading