Skip to content

Commit b1cac9f

Browse files
refactor(backup): simplify reminder engine to posture cadences
The engine asked one question in several voices and tracked state it did not need. It now answers "could you get your money back?" with one verb per posture. Delete the balance-tier machinery (BackupBalanceTier, the 1,000,000-sat tier, highestHandledBalanceTier) and the pending-action bookkeeping. The record is now the acknowledgement time plus a one-time milestone flag; keys written by the earlier shape are ignored rather than rejected, so no migration is needed for an unreleased feature. Make the cadence posture-dependent: 90 days for a vault-only wallet, 365 for one that already holds words. Anchor the schedule on the clock of the thing being urged rather than on the most recent backup of any kind — a both-backups wallet with a fresh vault and a two-year-old physical test was never reminded, which was the whole point of the reminder. Fire the 10,000,000-sat notice at most once per wallet, inclusive of the threshold, retired by either dismissing it or acting on it. Merge the both-backups posture into "test your backup" and drop the "review your backups" nag, which named no action. There is deliberately no vault or PIN freshness reminder: the fix for a single server-dependent recovery path is a physical backup, not a PIN rehearsal. The popup now asks one question and offers one action, with a low-emphasis dismissal that snoozes a full cycle and never records a test the app did not observe.
1 parent 1ab4b16 commit b1cac9f

11 files changed

Lines changed: 617 additions & 510 deletions

docs/backup-health-reminders.md

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,57 @@
11
# Backup health reminders
22

3-
Backup health reminders encourage users to periodically confirm that their wallet can still be recovered. They supplement the existing no-backup warning; they do not replace it.
3+
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.
4+
5+
| Situation | Honest answer | The one action |
6+
| --- | --- | --- |
7+
| Nothing backed up | No — the money is gone | Back up, urgently |
8+
| Encrypted Vault only | Probably — it needs the server up and your PIN | Add a physical backup |
9+
| Physical backup done (with or without a vault) | Yes — if the words are still findable and correct | Occasionally confirm that is still true |
10+
11+
## One surface per state
12+
13+
Three surfaces, three tenses, no overlap:
14+
15+
- the **every-launch warning** (`backup_warning_overlay.dart`) means "you are unprotected right now". Zero-backup only, unchanged by this feature.
16+
- the **reminder popup** means "it has been a while, or something changed". Every posture except zero-backup.
17+
- the **Backup Settings hero** means "here is your standing situation". Always available on the screen you visit deliberately.
18+
19+
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.
420

521
## Reminder matrix
622

7-
The reminder is evaluated for the default mainnet hot-wallet seed after at least one backup method has been verified. Eligible balances are aggregated separately as described below.
23+
The reminder is evaluated for the default mainnet hot-wallet seed once at least one backup method has been verified.
824

9-
| Verified backup posture | Reminder message | Primary action | Secondary action |
10-
| --- | --- | --- | --- |
11-
| No verified backup | No quarterly reminder. Existing no-backup warning behavior remains unchanged. | Existing backup flow | Existing dismissal behavior |
12-
| Recoverbull only | Explain that automatic recovery normally depends on the Recoverbull key server and recommend an independent physical backup. | Create and test a physical backup | Acknowledge the dependency risk |
13-
| Physical only | Ask the user to perform a health check of the physical backup they already have. Recoverbull is available only through a neutral link to other backup options. | Test the physical backup | Remind in three months |
14-
| Recoverbull and physical | Ask the user to review and test their available backups. | Review backup options | Remind in three months |
25+
| Verified backup posture | Reminder | Action |
26+
| --- | --- | --- |
27+
| No verified backup | None. The existing no-backup warning owns this state. ||
28+
| Encrypted Vault only | Every 90 days: without a physical backup you cannot recover if the vault server is unavailable. | Add a physical backup |
29+
| Physical backup, with or without a vault | Every 365 days since the physical backup was last tested. | Test the backup |
1530

16-
The reminder is shown only on the wallet home screen and only after higher-priority no-backup and legacy-storage warnings are clear.
31+
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.
1732

18-
## Timing and balance milestones
33+
## Timing
1934

20-
A scheduled reminder becomes due 90 full days after the most recent of:
35+
A scheduled reminder is anchored on the clock of the thing being urged:
2136

22-
- the latest completed physical backup;
23-
- the latest completed Recoverbull backup; or
24-
- the last time the user acknowledged the reminder.
37+
- vault only — the latest vault backup, or the last acknowledgement;
38+
- 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.
2539

26-
A verified backup record with no completion timestamp is treated as due immediately. Acknowledging a reminder starts a new 90-day interval.
40+
A verified backup with no completion timestamp is due immediately.
2741

28-
Balance milestones can show the reminder before the scheduled date:
42+
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.
2943

30-
- more than 1,000,000 sats; and
31-
- more than 10,000,000 sats after the first milestone has been handled.
44+
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.
3245

33-
The comparisons are strict: exactly 1,000,000 or 10,000,000 sats does not trigger a milestone. Each milestone is recorded after the user acknowledges the reminder or completes the selected backup action, so ordinary balance fluctuations do not repeatedly trigger it.
46+
## Balance milestone
3447

35-
Starting a backup action records a pending action. On the next evaluation, a backup completion timestamp at or after the action start handles the associated balance milestone. Cancelling the flow leaves the milestone due for the next app session.
48+
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.
49+
50+
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.
3651

3752
## Eligible wallets and balances
3853

39-
Only mainnet wallets whose keys are held on the device participate in reminder evaluation and balance milestones.
54+
Only mainnet wallets whose keys are held on the device participate in reminder evaluation and in the balance total.
4055

4156
Included:
4257

@@ -65,15 +80,21 @@ The overlay evaluates when wallet data or the Ark balance changes, after its fir
6580

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

68-
- the last acknowledgement time;
69-
- the highest handled balance tier;
70-
- a pending action start time; and
71-
- the pending action balance tier.
83+
- the last acknowledgement time; and
84+
- whether the balance milestone has been shown.
7285

73-
No mnemonic, seed, private key, vault key, or other secret is stored or logged by the reminder. Malformed, unsupported, or unreadable reminder 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.
86+
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.
7487

7588
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.
7689

90+
## Backup Settings screen
91+
92+
The screen is composed top to bottom as status rows, then at most one hero, then the settings menu:
93+
94+
1. **Status rows** — Physical Backup and Encrypted Vault, each Tested or Not tested, with a muted "Last tested …" line under a tested physical backup.
95+
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.
96+
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.
97+
7798
## Architecture
7899

79100
The feature follows the repository flow described in `ARCHITECTURE.md`:
@@ -86,7 +107,7 @@ The repository boundary uses a domain entity and a separate persistence model. R
86107

87108
## Tests
88109

89-
The focused suite covers the posture matrix, exact 90-day boundary, strict balance thresholds, testnet and non-local-signer exclusions, pending actions, persistence corruption, physical completion, and Recoverbull completion ordering.
110+
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.
90111

91112
Run it with:
92113

lib/features/backup_settings/data/models/backup_health_reminder_model.dart

Lines changed: 16 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,22 @@ import 'package:bb_mobile/features/backup_settings/domain/backup_health_reminder
33
class BackupHealthReminderModel {
44
final int version;
55
final int? lastAcknowledgedAtMillis;
6-
final String highestHandledBalanceTier;
7-
final int? pendingActionStartedAtMillis;
8-
final String pendingActionBalanceTier;
6+
final bool crossedTenMillionSats;
97

108
const BackupHealthReminderModel({
119
required this.version,
1210
required this.lastAcknowledgedAtMillis,
13-
required this.highestHandledBalanceTier,
14-
required this.pendingActionStartedAtMillis,
15-
required this.pendingActionBalanceTier,
11+
required this.crossedTenMillionSats,
1612
});
1713

18-
factory BackupHealthReminderModel.fromJson(
19-
Map<String, dynamic> json,
20-
) => BackupHealthReminderModel(
21-
version: _requiredInt(json, 'version'),
22-
lastAcknowledgedAtMillis: _optionalInt(json, 'lastAcknowledgedAt'),
23-
highestHandledBalanceTier: _requiredString(
24-
json,
25-
'highestHandledBalanceTier',
26-
),
27-
pendingActionStartedAtMillis: _optionalInt(json, 'pendingActionStartedAt'),
28-
pendingActionBalanceTier: _requiredString(json, 'pendingActionBalanceTier'),
29-
);
14+
/// Reads only the keys this record still has. Keys written by an earlier
15+
/// shape of the feature are ignored rather than rejected.
16+
factory BackupHealthReminderModel.fromJson(Map<String, dynamic> json) =>
17+
BackupHealthReminderModel(
18+
version: _requiredInt(json, 'version'),
19+
lastAcknowledgedAtMillis: _optionalInt(json, 'lastAcknowledgedAt'),
20+
crossedTenMillionSats: _optionalBool(json, 'crossedTenMillionSats'),
21+
);
3022

3123
factory BackupHealthReminderModel.fromEntity(
3224
BackupHealthReminderRecord record, {
@@ -36,43 +28,26 @@ class BackupHealthReminderModel {
3628
lastAcknowledgedAtMillis: record.lastAcknowledgedAt
3729
?.toUtc()
3830
.millisecondsSinceEpoch,
39-
highestHandledBalanceTier: record.highestHandledBalanceTier.name,
40-
pendingActionStartedAtMillis: record.pendingActionStartedAt
41-
?.toUtc()
42-
.millisecondsSinceEpoch,
43-
pendingActionBalanceTier: record.pendingActionBalanceTier.name,
31+
crossedTenMillionSats: record.crossedTenMillionSats,
4432
);
4533

4634
Map<String, dynamic> toJson() => {
4735
'version': version,
4836
'lastAcknowledgedAt': lastAcknowledgedAtMillis,
49-
'highestHandledBalanceTier': highestHandledBalanceTier,
50-
'pendingActionStartedAt': pendingActionStartedAtMillis,
51-
'pendingActionBalanceTier': pendingActionBalanceTier,
37+
'crossedTenMillionSats': crossedTenMillionSats,
5238
};
5339

5440
BackupHealthReminderRecord toEntity({required String masterFingerprint}) =>
5541
BackupHealthReminderRecord(
5642
masterFingerprint: masterFingerprint,
5743
lastAcknowledgedAt: _dateTimeFromMillis(lastAcknowledgedAtMillis),
58-
highestHandledBalanceTier: _tierFromName(highestHandledBalanceTier),
59-
pendingActionStartedAt: _dateTimeFromMillis(
60-
pendingActionStartedAtMillis,
61-
),
62-
pendingActionBalanceTier: _tierFromName(pendingActionBalanceTier),
44+
crossedTenMillionSats: crossedTenMillionSats,
6345
);
6446

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

69-
BackupBalanceTier _tierFromName(String name) {
70-
for (final tier in BackupBalanceTier.values) {
71-
if (tier.name == name) return tier;
72-
}
73-
return BackupBalanceTier.none;
74-
}
75-
7651
static int _requiredInt(Map<String, dynamic> json, String key) {
7752
final value = json[key];
7853
if (value is int) return value;
@@ -85,9 +60,10 @@ class BackupHealthReminderModel {
8560
throw FormatException('Invalid $key');
8661
}
8762

88-
static String _requiredString(Map<String, dynamic> json, String key) {
63+
static bool _optionalBool(Map<String, dynamic> json, String key) {
8964
final value = json[key];
90-
if (value is String) return value;
65+
if (value == null) return false;
66+
if (value is bool) return value;
9167
throw FormatException('Invalid $key');
9268
}
9369
}
Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,95 @@
1-
enum BackupHealthPosture { recoverbullOnly, physicalOnly, both }
1+
/// What a wallet can currently recover from.
2+
///
3+
/// There is no zero-backup value on purpose: that state is owned by the
4+
/// every-launch backup warning and by the Backup Settings hero, never by a
5+
/// reminder. [BackupHealthPosture.of] returns null for it.
6+
enum BackupHealthPosture {
7+
recoverbullOnly,
8+
physicalOnly,
9+
both;
210

3-
enum BackupHealthTrigger { scheduled, balanceMilestone }
4-
5-
enum BackupBalanceTier {
6-
none,
7-
oneMillion,
8-
tenMillion;
11+
/// How long the same advice waits before it is worth repeating.
12+
///
13+
/// A vault-only wallet has a single recovery path that depends on someone
14+
/// else, so it is asked more often than a wallet that already holds words.
15+
Duration get reminderInterval => switch (this) {
16+
BackupHealthPosture.recoverbullOnly => const Duration(days: 90),
17+
BackupHealthPosture.physicalOnly ||
18+
BackupHealthPosture.both => const Duration(days: 365),
19+
};
920

10-
bool isHigherThan(BackupBalanceTier other) => index > other.index;
21+
/// Whether the one action for this posture is creating a physical backup,
22+
/// as opposed to testing the physical backup that already exists.
23+
bool get urgesPhysicalBackup => this == BackupHealthPosture.recoverbullOnly;
1124

12-
static BackupBalanceTier highest(
13-
BackupBalanceTier first,
14-
BackupBalanceTier second,
15-
) => first.index >= second.index ? first : second;
25+
/// The posture for a set of verified backups, or null when nothing is
26+
/// backed up.
27+
static BackupHealthPosture? of({
28+
required bool isEncryptedVaultTested,
29+
required bool isPhysicalBackupTested,
30+
}) => switch ((isEncryptedVaultTested, isPhysicalBackupTested)) {
31+
(true, false) => recoverbullOnly,
32+
(false, true) => physicalOnly,
33+
(true, true) => both,
34+
(false, false) => null,
35+
};
1636
}
1737

38+
enum BackupHealthTrigger { scheduled, balanceMilestone }
39+
40+
/// Whether advice anchored at [anchor] is due again at [now].
41+
///
42+
/// A missing anchor (a backup marked tested without a completion date) and a
43+
/// clock that has moved backwards both count as due: repeating advice is
44+
/// better than silently stopping.
45+
bool isBackupReminderDue({
46+
required DateTime? anchor,
47+
required DateTime now,
48+
required Duration interval,
49+
}) =>
50+
anchor == null ||
51+
anchor.isAfter(now) ||
52+
!now.isBefore(anchor.add(interval));
53+
1854
class BackupHealthReminderRecord {
1955
final String masterFingerprint;
2056
final DateTime? lastAcknowledgedAt;
21-
final BackupBalanceTier highestHandledBalanceTier;
22-
final DateTime? pendingActionStartedAt;
23-
final BackupBalanceTier pendingActionBalanceTier;
57+
58+
/// Set once the user has been told, once per wallet lifetime, that the
59+
/// balance reached the milestone. A balance dropping back below it never
60+
/// clears this.
61+
final bool crossedTenMillionSats;
2462

2563
const BackupHealthReminderRecord({
2664
required this.masterFingerprint,
2765
this.lastAcknowledgedAt,
28-
this.highestHandledBalanceTier = BackupBalanceTier.none,
29-
this.pendingActionStartedAt,
30-
this.pendingActionBalanceTier = BackupBalanceTier.none,
66+
this.crossedTenMillionSats = false,
3167
});
3268

3369
BackupHealthReminderRecord copyWith({
3470
DateTime? lastAcknowledgedAt,
35-
BackupBalanceTier? highestHandledBalanceTier,
36-
DateTime? pendingActionStartedAt,
37-
bool clearPendingAction = false,
38-
BackupBalanceTier? pendingActionBalanceTier,
71+
bool? crossedTenMillionSats,
3972
}) => BackupHealthReminderRecord(
4073
masterFingerprint: masterFingerprint,
4174
lastAcknowledgedAt: lastAcknowledgedAt ?? this.lastAcknowledgedAt,
42-
highestHandledBalanceTier:
43-
highestHandledBalanceTier ?? this.highestHandledBalanceTier,
44-
pendingActionStartedAt: clearPendingAction
45-
? null
46-
: pendingActionStartedAt ?? this.pendingActionStartedAt,
47-
pendingActionBalanceTier: clearPendingAction
48-
? BackupBalanceTier.none
49-
: pendingActionBalanceTier ?? this.pendingActionBalanceTier,
75+
crossedTenMillionSats: crossedTenMillionSats ?? this.crossedTenMillionSats,
5076
);
5177
}
5278

5379
class BackupHealthDecision {
5480
final String masterFingerprint;
5581
final BackupHealthPosture posture;
5682
final BackupHealthTrigger trigger;
57-
final BackupBalanceTier currentBalanceTier;
83+
84+
/// When the physical backup was last tested, when it has been. Null for a
85+
/// vault-only wallet and for a tested backup with no recorded date; the UI
86+
/// must not claim a test date the app never observed.
87+
final DateTime? physicalBackupTestedAt;
5888

5989
const BackupHealthDecision({
6090
required this.masterFingerprint,
6191
required this.posture,
6292
required this.trigger,
63-
required this.currentBalanceTier,
93+
this.physicalBackupTestedAt,
6494
});
6595
}

lib/features/backup_settings/domain/usecases/acknowledge_backup_health_reminder_usecase.dart

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import 'package:bb_mobile/features/backup_settings/domain/backup_settings_failur
44
import 'package:bb_mobile/features/backup_settings/domain/repositories/backup_health_reminder_repository.dart';
55
import 'package:meta/meta.dart';
66

7+
/// Snoozes a reminder for a full cycle.
8+
///
9+
/// This never writes a tested timestamp: the Backup Settings screen keeps
10+
/// saying how long ago the backup was really tested while the popup is quiet.
711
class AcknowledgeBackupHealthReminderUsecase {
812
final BackupHealthReminderRepository _repository;
913
final DateTime Function() _clock;
@@ -28,11 +32,9 @@ class AcknowledgeBackupHealthReminderUsecase {
2832
return _repository.save(
2933
record.copyWith(
3034
lastAcknowledgedAt: _clock().toUtc(),
31-
highestHandledBalanceTier: BackupBalanceTier.highest(
32-
record.highestHandledBalanceTier,
33-
decision.currentBalanceTier,
34-
),
35-
clearPendingAction: true,
35+
crossedTenMillionSats:
36+
record.crossedTenMillionSats ||
37+
decision.trigger == BackupHealthTrigger.balanceMilestone,
3638
),
3739
);
3840
}

0 commit comments

Comments
 (0)