Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2433495
fix(wallet): make incompatible-data guidance and upgrade backups safe
lklimek Sep 15, 2026
c2b7e45
fix(wallet): address upgrade backup retention and cleanup reviews
lklimek Sep 16, 2026
61708ac
Merge branch 'v1.0-dev' into fix/release-review-blocking-findings
lklimek Sep 16, 2026
095f8c8
fix(wallet): clean crash snapshots and classify upgrade retries
lklimek Sep 17, 2026
38d872f
fix(storage): protect active snapshots and preserve validation retries
lklimek Sep 18, 2026
2b9e407
fix(storage): serialize compatibility open lifecycle
lklimek Sep 18, 2026
3c6dde4
Merge branch 'v1.0-dev' into fix/release-review-blocking-findings
lklimek Sep 22, 2026
7ab12b8
Merge remote-tracking branch 'origin/v1.0-dev' into fix/release-revie…
lklimek Sep 23, 2026
dcb6d8d
fix(wallet): keep resource-exhausted migrations retryable
lklimek Sep 23, 2026
14f08fb
fix(wallet): remove valid upgrade backups despite a rejected candidate
lklimek Sep 23, 2026
65d064a
Merge remote-tracking branch 'origin/v1.0-dev' into fix/release-revie…
lklimek Sep 24, 2026
f9081f3
fix(wallet): sync backup directories before reporting removal done
lklimek Sep 24, 2026
9624d01
fix(wallet): keep successful wallet open when post-open backup retent…
lklimek Sep 24, 2026
8d2d905
fix(wallet): keep current data openable and never drop the last upgra…
lklimek Sep 25, 2026
0d1c6a5
test(wallet): prove the current-only probe leaves no files behind
lklimek Sep 25, 2026
e64ba3b
fix(wallet): retain only a proven-usable upgrade snapshot
lklimek Sep 25, 2026
5058b11
Merge branch 'v1.0-dev' into fix/release-review-blocking-findings
lklimek Sep 28, 2026
89e77f3
fix(identity): persist upgrade-backup removal authorization across re…
lklimek Sep 28, 2026
820b5a6
fix(wallet): validate upgrade snapshots beneath symlinked ancestors
lklimek Sep 28, 2026
430a1ae
fix(identity): scope upgrade-backup removal to snapshots present at r…
lklimek Sep 29, 2026
72924cd
Merge remote-tracking branch 'origin/v1.0-dev' into fix/release-revie…
lklimek Sep 30, 2026
4dd746c
fix(wallet): expire upgrade backups by age instead of on identity rem…
lklimek Sep 30, 2026
e3773e0
fix(wallet): keep upgrade backups when removing a wallet
lklimek Sep 30, 2026
87d8260
fix(wallet): confirm stricter backup retention and harden pruning
lklimek Sep 30, 2026
81cdf8a
fix(ui): keep backup retention saves reliable across navigation and b…
lklimek Sep 30, 2026
0c66764
fix(storage): route every file deletion through one protected chokepoint
lklimek Sep 30, 2026
fbbb7f7
fix(storage): honor backup retention globally and harden the deletion…
lklimek Sep 30, 2026
0c9e4f9
fix(storage): retry the upgrade lifecycle lock through fork-inherited…
lklimek Sep 30, 2026
3fc061d
fix(storage): keep the newest usable upgrade backup and bound failed-…
lklimek Sep 30, 2026
ea999c4
fix(storage): never prune a backup whose usability is unproven
lklimek Sep 30, 2026
4046287
fix(storage): sweep crash-left legacy backup copies
lklimek Sep 30, 2026
15ef632
fix(storage): never let a legacy backup name collision block startup
lklimek Sep 30, 2026
5ff47a7
refactor(storage): drop the dead legacy data.db backup write path
lklimek Sep 30, 2026
c122e55
Merge remote-tracking branch 'origin/v1.0-dev' into fix/release-revie…
lklimek Sep 30, 2026
660e4b3
test(storage): trim duplicated backup-retention tests and docs
lklimek Sep 30, 2026
eea9e76
refactor(storage): make upgrade-backup housekeeping best-effort
lklimek Sep 30, 2026
681ddac
fix(storage): never treat a hard-linked file as an upgrade backup
lklimek Sep 30, 2026
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Code lives by responsibility, not convenience:
- **`ui/components/`** — reusable **Component-pattern widgets ONLY**: a `show()` plus a `ComponentResponse`, a display-only render widget, or component infrastructure. If it does not render egui, it is not a component.
- **`ui/state/`** — non-widget UI state: per-screen view-models and async fetch-state caches (e.g. `TrackedAssetLockCache`). Owned by screens, may return `BackendTask`, render nothing.
- **`src/mcp/tools/`** — MCP tool logic, one file per domain (e.g. `wallet.rs`, `shielded.rs`, `identity.rs`) with multiple tool structs per file; never in `src/bin/det_cli/`.
- **`src/utils/file_deletion.rs`** — the single file-deletion chokepoint. Every production on-disk deletion goes through `delete_file` (or `delete_tree`) with a `DeletionIntent` stating its purpose; the chokepoint hard-refuses live databases, their sidecars, the vault and `.env`. `clippy.toml` disallows raw `std::fs`/`tokio::fs` remove calls; any deliberate exception gets `#[expect(clippy::disallowed_methods, reason = "...")]` at its call site.
- **`src/localization.rs`** — localization logic. **`src/ui/theme.rs`** — theme/alignment helpers.

Discriminator for `ui/components/` vs `ui/state/`: *does it render egui (`show`/`ui`/a render fn)?* Yes → component. No → state.
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- **Upgrade backup retention setting**: Settings → Upgrade Backups controls how
long the backup copies taken before a storage upgrade are kept. By default,
backups older than 90 days are now deleted automatically; choose another
period (1 to 3650 days) or keep them forever. The newest complete backup of
each database is always kept (or the newest backup, if none is complete), and
a shorter period asks for confirmation because it deletes backups at once.

- **Add wallet-created identity keys**: for identities loaded from a wallet on
this device, the Add Key screen defaults to "Create from wallet", which
creates an ECDSA_SECP256K1 or ECDSA_HASH160 key from that wallet so it can be
Expand Down Expand Up @@ -147,6 +154,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **Safer guidance for wallet data this version cannot open**: the message no
longer tells you to remove your local wallet data, which could have deleted
the `secrets` folder along with keys no recovery phrase can restore. It asks
you to write down your recovery phrases and imported keys in the version you
used before, close every running instance, and set aside the `.sqlite` files
while keeping the `secrets` folder. A database held by another session asks
you to close that session and try again.

- **Upgrade backups no longer pile up**: repeated failed upgrades no longer
leave an identical backup on every attempt, and a failed upgrade that can
never succeed shows its message instead of retrying silently. Clearing a
network's data removes that network's upgrade backups; backups of the shared
app data are left to the retention setting. Removing a wallet or an identity
keeps upgrade backups.

- **Clearing a network's data works with moved chain data**: chain-sync data
that was moved to another disk and linked back no longer stops the clear.

- **Clearer identity funding errors**: when the wallet cannot save the data a
payment needs, the message now distinguishes a busy or full store from other
failures and says how to retry.

- **Identity keys added by hand no longer disappear**: unlocking a wallet,
starting the app or loading an identity from a wallet refreshed the identity
with only the keys the wallet can recreate, so keys you had pasted or
Expand Down
9 changes: 9 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Every production file deletion goes through `src/utils/file_deletion.rs`.
disallowed-methods = [
{ path = "std::fs::remove_file", reason = "delete through utils::file_deletion::delete_file" },
{ path = "std::fs::remove_dir", reason = "delete through utils::file_deletion::delete_tree" },
{ path = "std::fs::remove_dir_all", reason = "delete through utils::file_deletion::delete_tree" },
{ path = "tokio::fs::remove_file", reason = "delete through utils::file_deletion::delete_file" },
{ path = "tokio::fs::remove_dir", reason = "delete through utils::file_deletion::delete_tree" },
{ path = "tokio::fs::remove_dir_all", reason = "delete through utils::file_deletion::delete_tree" },
]
34 changes: 30 additions & 4 deletions docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,36 @@ typed identity rows remain in the backup, while their opaque local metadata is
preserved. An ambiguous or malformed saved identity roster fails closed.
The encrypted seed vault is not migrated or rewritten.

Keep the retained backups. Downgrading does not automatically reverse the
database conversion; recovery requires the corresponding backup and the prior
application version. Validation uses synthetic upstream fixtures, not a user's
real profile.
The bridge takes its backup only after the converted copy validates, just before
the rebuild. Opening a database never deletes a published backup except an exact
duplicate of a newer backup of the same migration (each failed attempt copies the
unchanged original again); it also removes unfinished `.pending` copies left by a
crash. Upstream `backups/auto/pre-migration-<database>-*.db` snapshots are covered
too. Housekeeping is best-effort: an error is logged and retried on the next open,
and never blocks opening. Backup candidates must have the exact database-specific
name format and be regular files; symbolic links and directories are skipped, and
the deletion chokepoint refuses hard links and live databases.

Backups older than a user-configurable retention period (default 90 days, or
never) are deleted by one global policy covering every database in the data
directory: bridge, upstream pre-migration, and legacy `data.db` backups. The
newest usable backup of each database is always kept, whatever its age: an empty or
truncated file never counts, and the upstream snapshot of the latest migration is
kept as well. If no backup is usable, the newest file is kept anyway. A backup
dated in the future does not displace one dated in the past, but only until real
time reaches that date; the migration order is the only clock-proof signal. A copy
that cannot be read, or has a non-empty rollback journal beside it, is never
deleted automatically. This version never writes new legacy `data.db` copies: it
opens an existing `data.db` read-only, so only copies earlier versions left behind
are expired.
Clearing a network's data deletes that network's wallet database backups, because
they copy its wallet and identity history; backups of the shared `det-app.sqlite`
also hold other networks' data and stay under retention. Removing a single wallet
or identity deletes no backups.

Downgrading does not automatically reverse the database conversion; recovery
requires the corresponding backup and the prior application version. Validation
uses synthetic upstream fixtures, not a user's real profile.

## Validation and limits

Expand Down
13 changes: 13 additions & 0 deletions docs/user-stories.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ As a user, I want to remove a wallet I no longer need so that it does not clutte

- Confirmation prompt before removal.
- Current wallet data is deleted from local storage. If an older recovery database exists, it remains untouched.
- Upgrade backups are not deleted with the wallet, since they also hold other wallets' data; they expire through backup retention (WAL-034).

### WAL-008: View wallet balances [Implemented]
**Persona:** Alex, Priya, Jordan
Expand Down Expand Up @@ -336,6 +337,18 @@ As a user updating from any earlier version I still have installed, I want every
- Starting the updated app a second time changes nothing further, and a user whose data was already current keeps an untouched database rather than a needlessly rewritten one.
- Currently a gap: each migration mechanism is unit-tested on synthetic data, but no automated check upgrades a data directory a *released* build actually wrote and confirms the result. The design for closing it is `docs/ai-design/2026-09-10-migration-matrix/design.md`; related implemented behaviour is covered by WAL-032, IDN-016 and IDN-020.

### WAL-034: Control how long upgrade backups are kept [Implemented]
**Persona:** Alex, Priya, Jordan

As a user whose wallet data was copied before a storage upgrade, I want old upgrade backups to be deleted after a while so that copies of my wallet data do not stay on this device forever.

- Upgrade backups older than 90 days are deleted automatically by default, covering app, wallet, upstream pre-migration and legacy `data.db` backups.
- Settings → Upgrade Backups lets the user change the period (1 to 3650 days) or keep backups forever. Turning automatic deletion on lets the user choose the period before applying it, and any stricter setting asks for confirmation first, because it deletes backups immediately. A period outside the range is shown with an error and never saved.
- The setting is one policy for every backup in the data directory, whichever network is open; a saved change applies to every network's backups as soon as the wallet data has opened.
- The newest complete backup of each database is always kept, whatever its age, so the last recovery copy is never deleted automatically. A backup dated in the future because the system clock ran ahead is not treated as the newest until that date arrives. An empty or cut-off copy never counts as that backup; if no complete copy exists, the newest copy is kept anyway. A copy that cannot be read is never deleted automatically.
- With "keep forever", no published backup is deleted automatically. Opening the data removes only unfinished copies left by a crash and exact duplicates of a newer backup.
- Removing a wallet or an identity does not delete upgrade backups. Clearing a network's data deletes that network's wallet backups; backups of the shared app data stay under the retention setting.

---

## Send and Receive (SND)
Expand Down
51 changes: 45 additions & 6 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,15 @@ pub enum AppAction {
SwitchIdentityHubTab(crate::ui::identity::IdentityHubTab),
}

/// Whether results of the task behind `context` belong to the network chooser (settings)
/// screen whichever screen is visible: it tracks the task and must hear how it ended.
fn network_chooser_owns_task(context: &BackendTaskContext) -> bool {
matches!(
context,
BackendTaskContext::ClearNetworkDatabase | BackendTaskContext::UpdateBackupRetention
)
}

impl BitOrAssign for AppAction {
fn bitor_assign(&mut self, rhs: Self) {
if matches!(rhs, AppAction::None) {
Expand Down Expand Up @@ -1446,7 +1455,8 @@ impl AppState {
pub(crate) fn boot_inputs()
-> Result<(PathBuf, Arc<Database>), Box<dyn std::error::Error + Send + Sync>> {
let data_dir = crate::boot::prepare_environment()?;
let db_file_path = data_file_path(&data_dir, "data.db")?;
let db_file_path =
data_file_path(&data_dir, crate::database::legacy_backups::LEGACY_DATABASE)?;
let db = if db_file_path.exists() {
Arc::new(Database::open_legacy_read_only(&db_file_path)?)
} else {
Expand Down Expand Up @@ -2955,6 +2965,16 @@ impl App for AppState {
);
}
}
result @ BackendTaskSuccessResult::UpdatedBackupRetention { .. } => {
// The settings form waits for this answer even after the user
// navigates away, so it goes to the chooser, not the visible screen.
if let Some(screen) = self
.main_screens
.get_mut(&RootScreenType::RootScreenNetworkChooser)
{
screen.display_backend_task_result(&context, result);
}
}
BackendTaskSuccessResult::DashPayIncomingDetected(outputs) => {
// The EventBridge surfaced received outputs on a
// freshly-seen wallet transaction. Run the owner-
Expand Down Expand Up @@ -3184,13 +3204,13 @@ impl App for AppState {
&err,
);
self.route_contact_request_error_to_hidden_hub(&err);
let is_database_clear = context == BackendTaskContext::ClearNetworkDatabase;
let suppress_stale_error = !is_database_clear
let chooser_owned = network_chooser_owns_task(&context);
let suppress_stale_error = !chooser_owned
&& !recovery_delivered
&& self
.visible_screen_mut()
.should_suppress_backend_task_error(&context, &err);
if is_database_clear {
if chooser_owned {
if let Some(screen) = self
.main_screens
.get_mut(&RootScreenType::RootScreenNetworkChooser)
Expand All @@ -3204,7 +3224,7 @@ impl App for AppState {
// Let the screen handle specific error types first.
// If handled, skip the generic error banner.
let handled = suppress_stale_error
|| (!is_database_clear
|| (!chooser_owned
&& !recovery_delivered
&& self.visible_screen_mut().display_task_error(&err));

Expand Down Expand Up @@ -3241,7 +3261,7 @@ impl App for AppState {
}
_ => {}
}
if !is_database_clear && !recovery_delivered {
if !chooser_owned && !recovery_delivered {
self.visible_screen_mut()
.display_message(&msg, MessageType::Error);
}
Expand Down Expand Up @@ -4513,3 +4533,22 @@ mod shutdown_tests {
);
}
}

#[cfg(test)]
mod network_chooser_routing_tests {
use super::*;
use crate::backend_task::system_task::SystemTask;
use crate::model::backup_retention::BackupRetention;

/// A retention save outlives navigation: its result and error must reach the
/// chooser even when another screen is visible, or its form stays disabled.
#[test]
fn backup_retention_task_results_route_to_network_chooser() {
let task = BackendTask::SystemTask(SystemTask::UpdateBackupRetention(
BackupRetention::KeepForever,
));
let context = BackendTaskContext::from(&task);
assert_eq!(context, BackendTaskContext::UpdateBackupRetention);
assert!(network_chooser_owns_task(&context));
}
}
5 changes: 4 additions & 1 deletion src/app_dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,13 @@ pub fn copy_env_file_if_not_exists() {
ensure_env_file(&app_data_dir);
}

/// File name of the environment config in the data directory.
pub const ENV_FILE: &str = ".env";

/// If .env file does not exist in the given data directory,
/// copy the bundled `.env.example` file there.
pub fn ensure_env_file(data_dir: &Path) {
let env_file_in_app_dir = data_dir.join(".env");
let env_file_in_app_dir = data_dir.join(ENV_FILE);
BundledResource::DotEnvExample
.write_to_file(&env_file_in_app_dir, false)
.expect("Failed to write bundled .env.example file");
Expand Down
Loading
Loading