Skip to content

Commit 07daf0b

Browse files
feat(backup): port the reminder engine and redesigned backup screen
Brings SatoshiPortal#2453 (13 commits) into the Get Paid stack. The screen is a merge, not a copy: the fork already wraps a second cubit and renders the inline "Bull backup" metadata card, so the upstream skeleton is rebuilt around both. The reminder engine is new to the fork. It answers one question — if this phone vanished right now, could you get your money back? — and each posture gets exactly one verb: - vault only, no physical: every 90 days, "add a physical backup" - physical done (± vault): every 365 days since the last test - a one-time popup the first time the device-key balance is observed at or above 10,000,000 sats - zero backup makes NO decision here; that state stays owned by the existing every-launch backup warning overlay Each cadence is anchored on the clock of the thing being urged, so a fresh vault write cannot silence a stale physical backup. Dismissing snoozes the popup for a full cycle and never touches last_tested, so the screen keeps telling the truth while the popup is quiet. There is deliberately no PIN or vault-freshness reminder and no both-verified "review" nag: neither has an action behind it. The screen becomes status rows -> posture hero -> Start Backup button -> menu rows, each section built by its own function so the fork's metadata insertions land without disturbing the others. Start Backup is a button rather than a settings row, and it is suppressed only in the zero-backup state where the hero already offers it. Recoverbull: the vault permission gate is gone (nothing else in the fork consumed FetchPermission/AllowPermission), a custom key server URL now warns before it is saved, and user-facing "Recoverbull" vocabulary is retired in favour of "encrypted vault" — while keeping the recoverbull.com domain strings and the four server-product strings. Localization follows the fork rule: retired wording is deleted from every locale and re-added EN-only so stale translations cannot resurface; unchanged wording keeps its translations. 9 keys deleted, 4 reworded, 21 added.
1 parent b5c8669 commit 07daf0b

103 files changed

Lines changed: 4157 additions & 1248 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

FEATURES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ graph TB
8585
BACKUPS --> WALLET_BACKUP
8686
BACKUPS --> WALLETS
8787
BTC_PRICE --> SETTINGS
88+
ONBOARDING --> BACKUPS
8889
BTCPAY --> BIP85_REGISTRY
8990
BTCPAY --> DETERMINISTIC_WALLETS
9091
BTCPAY --> KEYCHAIN_MANIFEST

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Automated selection of the wallet can be overridden by the user at any time. Thi
7070
- BIP39 passphrase is also stored in secure storage, and can be viewed only via the application.
7171
- When initializing the app, a single-sig hot wallet wallet is created. By default, this wallet does not have a BIP39 passphrase.
7272
- A warning is displayed until the user has tested their backup by successfully entering the mnemonic.
73+
- Verified backups receive periodic health reminders based on elapsed time and mainnet hot-wallet balance milestones. See [Backup health reminders](docs/backup-health-reminders.md) for the exact behavior.
7374

7475
### Hardware wallets and watch-only wallets
7576

docs/backup-health-reminders.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Backup health reminders
2+
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.
20+
21+
## Reminder matrix
22+
23+
The reminder is evaluated for the default mainnet hot-wallet seed once at least one backup method has been verified.
24+
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 |
30+
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.
32+
33+
## Timing
34+
35+
A scheduled reminder is anchored on the clock of the thing being urged:
36+
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.
39+
40+
A verified backup with no completion timestamp is due immediately.
41+
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.
43+
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.
45+
46+
## Balance milestone
47+
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.
51+
52+
## Eligible wallets and balances
53+
54+
Only mainnet wallets whose keys are held on the device participate in reminder evaluation and in the balance total.
55+
56+
Included:
57+
58+
- default Bitcoin and Liquid hot-wallet balances;
59+
- imported hot wallets that sign locally; and
60+
- the Ark balance, which is mainnet-only in the app.
61+
62+
Excluded:
63+
64+
- every testnet wallet and testnet backup record;
65+
- watch-only wallets;
66+
- watch-signer wallets; and
67+
- hardware wallets.
68+
69+
## Backup completion timestamps
70+
71+
Backup timestamps represent successful completion, not the start of a flow.
72+
73+
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.
74+
75+
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.
76+
77+
## Evaluation and persistence
78+
79+
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.
80+
81+
Reminder state is stored locally in a versioned SharedPreferences record keyed by master fingerprint. It contains only:
82+
83+
- the last acknowledgement time; and
84+
- whether the balance milestone has been shown.
85+
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.
87+
88+
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.
89+
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+
98+
## Architecture
99+
100+
The feature follows the repository flow described in `ARCHITECTURE.md`:
101+
102+
```text
103+
overlay -> cubit -> use cases -> reminder repository -> SharedPreferences
104+
```
105+
106+
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.
107+
108+
## Tests
109+
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.
111+
112+
Run it with:
113+
114+
```sh
115+
fvm flutter test test/features/backup_settings \
116+
test/features/onboarding/domain/complete_physical_backup_verification_usecase_test.dart \
117+
test/features/test_wallet_backup/domain/complete_physical_backup_verification_usecase_test.dart \
118+
test/features/recoverbull/domain/complete_encrypted_vault_backup_usecase_test.dart \
119+
test/features/recoverbull/recoverbull_bloc_test.dart
120+
```

integration_test/fiat_settlement_funded_witness_test.dart

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,18 @@ Future<bool> _prepareWitnessWallet({
314314
return true;
315315
}
316316

317-
final (mnemonicWords, passphrase) =
318-
await locator<GetMnemonicFromFingerprintUsecase>().execute(fingerprint);
317+
final (
318+
mnemonicWords,
319+
passphrase,
320+
) = switch (await locator<GetMnemonicFromFingerprintUsecase>().execute(
321+
fingerprint,
322+
)) {
323+
Ok(:final value) => value,
324+
// The witness run cannot capture a seed it could not read.
325+
Err(:final failure) => fail(
326+
'could not read the witness seed: ${failure.logMessage}',
327+
),
328+
};
319329
final seedPath = await config.writeSeedCapture({
320330
'schema': 'fiat-witness-seed-capture/v1',
321331
'run_id': config.runId,

lib/core/recoverbull/data/datasources/recoverbull_settings_datasource.dart

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import 'package:bb_mobile/core/storage/sqlite_database.dart';
2-
import 'package:drift/drift.dart';
32

43
class RecoverbullSettingsDatasource {
54
final SqliteDatabase _sqlite;
@@ -24,17 +23,4 @@ class RecoverbullSettingsDatasource {
2423
.getSingle();
2524
return Uri.parse(row.url);
2625
}
27-
28-
Future<void> allowPermission(bool isGranted) async {
29-
await _sqlite.managers.recoverbull.update(
30-
(f) => f(id: const Value(1), isPermissionGranted: Value(isGranted)),
31-
);
32-
}
33-
34-
Future<bool> fetchPermission() async {
35-
final row = await _sqlite.managers.recoverbull
36-
.filter((f) => f.id(1))
37-
.getSingle();
38-
return row.isPermissionGranted;
39-
}
4026
}

lib/core/recoverbull/data/repository/recoverbull_repository.dart

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,6 @@ class RecoverBullRepository {
147147
await recoverbullSettingsDatasource.store(url);
148148
}
149149

150-
Future<void> allowPermission(bool isGranted) async {
151-
await recoverbullSettingsDatasource.allowPermission(isGranted);
152-
}
153-
154-
Future<bool> fetchPermission() async {
155-
return await recoverbullSettingsDatasource.fetchPermission();
156-
}
157-
158150
// Mirrors the legacy `ServerError.fromException`, null-safe on the 429 path.
159151
RecoverBullCoreFailure _mapKeyServer(recoverbull.KeyServerException e) {
160152
final code = e.code;

lib/core/recoverbull/domain/usecases/allow_permission_usecase.dart

Lines changed: 0 additions & 11 deletions
This file was deleted.

lib/core/recoverbull/domain/usecases/create_encrypted_vault_usecase.dart

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ class CreateEncryptedVaultUsecase {
2727
// repo. The local try/catch is the boundary for the wallet/seed calls; the
2828
// recoverbull repo already returns a Result that we forward.
2929
Future<
30-
Result<({EncryptedVault vault, String vaultKey}), RecoverBullCoreFailure>
30+
Result<
31+
({EncryptedVault vault, String vaultKey, String walletId}),
32+
RecoverBullCoreFailure
33+
>
3134
>
3235
execute() async {
3336
try {
@@ -44,10 +47,6 @@ class CreateEncryptedVaultUsecase {
4447

4548
// The default wallet is used to derive the backup key
4649
final defaultWallet = defaultBitcoinWallets.first;
47-
await _walletRepository.updateEncryptedBackupTime(
48-
time: DateTime.now(),
49-
walletId: defaultWallet.id,
50-
);
5150
final defaultSeed = await _seedRepository.get(
5251
defaultWallet.masterFingerprint,
5352
);
@@ -90,7 +89,10 @@ class CreateEncryptedVaultUsecase {
9089
plaintext: plaintext,
9190
derivationPath: derivationPath,
9291
)
93-
.map((vault) => (vault: vault, vaultKey: backupKey));
92+
.map(
93+
(vault) =>
94+
(vault: vault, vaultKey: backupKey, walletId: defaultWallet.id),
95+
);
9496
} catch (e, st) {
9597
log.severe(message: 'createEncryptedVault failed', error: e, trace: st);
9698
return Err(RecoverBullUnexpectedCoreFailure(e.toString()));

lib/core/recoverbull/domain/usecases/fetch_permission_usecase.dart

Lines changed: 0 additions & 11 deletions
This file was deleted.

lib/core/recoverbull/recoverbull_locator.dart

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,9 @@ import 'package:bb_mobile/core/recoverbull/data/datasources/recoverbull_settings
55
import 'package:bb_mobile/core/recoverbull/data/repository/file_system_repository.dart';
66
import 'package:bb_mobile/core/recoverbull/data/repository/google_drive_repository.dart';
77
import 'package:bb_mobile/core/recoverbull/data/repository/recoverbull_repository.dart';
8-
import 'package:bb_mobile/core/recoverbull/domain/usecases/allow_permission_usecase.dart';
98
import 'package:bb_mobile/core/recoverbull/domain/usecases/check_server_connection_usecase.dart';
109
import 'package:bb_mobile/core/recoverbull/domain/usecases/create_encrypted_vault_usecase.dart';
1110
import 'package:bb_mobile/core/recoverbull/domain/usecases/decrypt_vault_usecase.dart';
12-
import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_permission_usecase.dart';
1311
import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_recoverbull_url_usecase.dart';
1412
import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_vault_key_from_server_usecase.dart';
1513
import 'package:bb_mobile/core/recoverbull/domain/usecases/google_drive/connect_google_drive_usecase.dart';
@@ -196,15 +194,5 @@ class RecoverbullLocator {
196194
recoverBullRepository: locator<RecoverBullRepository>(),
197195
),
198196
);
199-
locator.registerFactory<AllowPermissionUsecase>(
200-
() => AllowPermissionUsecase(
201-
recoverBullRepository: locator<RecoverBullRepository>(),
202-
),
203-
);
204-
locator.registerFactory<FetchPermissionUsecase>(
205-
() => FetchPermissionUsecase(
206-
recoverBullRepository: locator<RecoverBullRepository>(),
207-
),
208-
);
209197
}
210198
}

0 commit comments

Comments
 (0)