Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Allowing 3.x needs an upstream change in `dashpay/rust-dashcore` first. A TODO
in `Cargo.toml` marks the re-check.

- **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 now
asks you to write down your recovery phrases and imported keys in the version
you used before, close every running instance, then set aside the `.sqlite`
files together with their matching `-wal` and `-shm` files and keep the
`secrets` folder. Imported private keys are listed separately from recovery phrases.
A database temporarily held by another session now asks you to close that
session and try again instead of reporting incompatible data.

- **Upgrade backups no longer pile up or outlive deleted data**: a failed
database upgrade no longer leaves a new backup on every attempt. Each
database keeps at most one upgrade backup, and it is deleted when you remove
a wallet or identity or clear a network's data. An upgrade that can never
succeed now shows its message instead of being retried silently.
Retention and deletion cover upstream pre-migration snapshots too. Cleanup
rejects links and non-files, reports pruning failures, and retries incomplete
identity backup deletion at the next startup.
Cleanup also removes unfinished snapshots left by a crash and runs when
removing a wallet without an available backend. Temporary storage and memory
failures remain retryable; permission and invalid-input failures surface directly.
Concurrent sessions now protect active snapshots from cleanup, and staged
validation preserves retry options and guidance for temporary resource failures.
One lock now covers the complete open, upgrade, backup, and retention cycle.

### Added

- Migration tests also replay public user/DPNS and Evonode identities serialized
Expand Down
10 changes: 9 additions & 1 deletion docs/ai-design/2026-09-10-platform-pin/upgrade-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@ 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
The bridge takes its backup only after the converted copy validates, just before
the rebuild, and keeps at most one backup per database. Retention includes upstream
`backups/auto/pre-migration-<database>-*.db` snapshots and runs before and after
storage opens, including failed opens. Pruning errors stop the operation; backup
candidates must have the exact database-specific name format and be regular files,
not symbolic links (or hard links on Unix). Identity cleanup retains its pending
manifest until backup deletion succeeds, so startup retries incomplete cleanup.
Deleting a wallet or an identity, or clearing a network's data, also deletes these backups, because they
copy wallet and identity history. 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.
Expand Down
127 changes: 112 additions & 15 deletions src/backend_task/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,27 @@ pub enum TaskError {
/// An identity-funding account was derived but could not be saved. Saving
/// it is what lets a restart find the account again, so the operation is
/// stopped rather than left able to strand a funding lock the app could no
/// longer spend. The technical cause lives in `Debug` and the logs.
/// longer spend. Covers non-retryable store failures (constraint, fatal);
/// retryable ones use [`Self::IdentityFundingAccountPersistBusy`].
#[error(
"Your wallet could not save the account this payment needs. Check that your disk is not full, then try again."
"Your wallet could not save the information it needs for this payment. Restart the application and try again."
)]
IdentityFundingAccountPersistFailed {
#[source]
source: Box<platform_wallet::changeset::PersistenceError>,
},

/// Like [`Self::IdentityFundingAccountPersistFailed`], but the store kept
/// reporting a retryable condition until the retry budget ran out. Upstream
/// classifies a busy database and a full disk alike, so the copy names both.
#[error(
"Your wallet could not save the information it needs for this payment because its data is busy or the disk is full. Close any other copy of Dash Evo Tool, make sure there is free disk space, and try again."
)]
IdentityFundingAccountPersistBusy {
#[source]
source: Box<platform_wallet::changeset::PersistenceError>,
},

/// Single-key wallets are not supported in this version. Their data is
/// preserved; HD (recovery-phrase) wallets remain fully functional.
#[error(
Expand Down Expand Up @@ -423,6 +435,15 @@ pub enum TaskError {
source: platform_wallet_storage::WalletStorageError,
},

/// Another process currently owns the wallet database write lock.
#[error(
"Your wallet data is open in another Dash Evo Tool window or command-line session. Close it and try again."
)]
WalletStorageInUse {
#[source]
source: platform_wallet_storage::WalletStorageError,
},

/// A pinned-PR wallet database could not be upgraded without losing data.
#[error(transparent)]
PlatformDatabaseUpgrade {
Expand Down Expand Up @@ -473,11 +494,11 @@ pub enum TaskError {
///
/// The migration diagnostic is preserved through the `#[source]` chain for
/// logs and the `Debug` view; it is kept out of the user-facing `Display`
/// copy. On the active development branch the storage layout changed in an
/// incompatible way, so the practical action is to remove the local wallet
/// data and let the app recreate it (see `docs/kv-keys.md`).
/// copy. The copy names only the rebuildable `.sqlite` stores and tells the
/// user to keep the `secrets` folder: the vault holds imported and
/// masternode keys that no recovery phrase can re-derive.
#[error(
"Your wallet data is not compatible with this version of the app and cannot be opened. Remove the local wallet data so the app can create it fresh, then restart."
"Your wallet data was saved by an app version that this version cannot open, so it was left unchanged. Your recovery phrases and keys are still stored on this device. First open the app version you used before and write down the recovery phrase of every wallet. Separately, write down each imported private key. Close all Dash Evo Tool windows and command-line sessions. Then move each .sqlite file together with its matching .sqlite-wal and .sqlite-shm files, if present, out of the app data folder, leave the secrets folder where it is, and start this app again."
)]
Comment thread
lklimek marked this conversation as resolved.
Resolved
Comment thread
lklimek marked this conversation as resolved.
Resolved
WalletDataIncompatible {
#[source]
Expand Down Expand Up @@ -1240,7 +1261,7 @@ pub enum TaskError {
/// The identity and its private keys are gone, but at least one optional
/// owner-scoped sidecar could not be removed.
#[error(
"The identity was removed, but some DashPay or token-list data may still be stored on this device. The app will keep trying to clear this local data automatically."
"The identity was removed, but some local identity data or upgrade backups may still be stored on this device. The app will keep trying to clear this local data automatically."
)]
IdentitySidecarCleanupIncomplete,

Expand Down Expand Up @@ -2833,7 +2854,7 @@ impl TaskError {

/// Map a wallet-storage open failure to the right user-facing variant.
///
/// Three storage failures get honest, distinct copy; everything else keeps
/// Four storage failures get honest, distinct copy; everything else keeps
/// the generic disk/IO message:
///
/// - A forward-version database (written by a newer build, schema beyond
Expand All @@ -2843,14 +2864,19 @@ impl TaskError {
/// - A divergent migration history (e.g. a database written under an
/// earlier, incompatible storage layout that this build's migrations
/// cannot reconcile) is surfaced as [`Self::WalletDataIncompatible`] so
/// the banner tells the user to remove the local wallet data — freeing
/// disk space or restarting never resolves a structural mismatch.
/// the banner tells the user to back up keys and set the databases aside
/// while keeping the vault — freeing disk space or restarting never
/// resolves a structural mismatch.
/// - A migration blocked by another SQLite writer is surfaced as
/// [`Self::WalletStorageInUse`] so the user can close the competing
/// process and retry instead of following incompatible-data recovery.
/// - Every other storage failure keeps the generic disk/IO copy via
/// [`Self::WalletStorage`].
///
/// Discrimination is on the typed upstream variant
/// (`WalletStorageError::SchemaVersionUnsupported` /
/// `WalletStorageError::Migration`), never on its `Display` text.
/// `WalletStorageError::Migration`) and the typed migration source chain,
/// never on `Display` text.
pub fn from_wallet_storage_open_error(
source: platform_wallet_storage::WalletStorageError,
) -> Self {
Comment on lines 3041 to 3043

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SEC-001 · MEDIUM · BLOCKING — A permission-denied / read-only migration failure still gets the terminal "move your .sqlite files aside" guidance

A permission hiccup and you're already handing the user a six-step 'write down your recovery phrases' eulogy? AccessDenied deserves the same dignified retry path StorageFull got — right now it's misclassified as terminal, and your own last commit admits the mapping isn't settled. Fix the classification before this ships.

Expand All @@ -2862,13 +2888,40 @@ impl TaskError {
found,
max_supported,
},
other @ platform_wallet_storage::WalletStorageError::Migration(_)
if Self::wallet_storage_error_is_in_use(&other) =>
{
Self::WalletStorageInUse { source: other }
}
other @ platform_wallet_storage::WalletStorageError::Migration(_) => {
Comment thread
lklimek marked this conversation as resolved.
Resolved
Self::WalletDataIncompatible { source: other }
}
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
other => Self::WalletStorage { source: other },
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
}
}

fn wallet_storage_error_is_in_use(
source: &platform_wallet_storage::WalletStorageError,
) -> bool {
let mut cause: Option<&(dyn std::error::Error + 'static)> = Some(source);
while let Some(error) = cause {
if error
.downcast_ref::<rusqlite::Error>()
.and_then(rusqlite::Error::sqlite_error_code)
.is_some_and(|code| {
matches!(
code,
rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked
)
})
{
return true;
}
cause = error.source();
}
false
}

/// Returns `true` when this is a [`Self::SecretStore`] open failure caused
/// specifically by a vault passphrase mismatch
/// ([`SecretStoreError::WrongPassphrase`]).
Expand Down Expand Up @@ -5811,10 +5864,45 @@ mod tests {
.expect_err("divergent checksum must abort")
}

fn busy_migration_error() -> refinery::Error {
use refinery::{Migration, Runner};

let dir = tempfile::tempdir().expect("temporary directory");
let path = dir.path().join("wallet.sqlite");
let writer = rusqlite::Connection::open(&path).expect("writer connection");
writer
.execute_batch("BEGIN IMMEDIATE; CREATE TABLE held (id INTEGER);")
.expect("hold write lock");
let mut contender = rusqlite::Connection::open(&path).expect("contender connection");
contender
.busy_timeout(std::time::Duration::from_millis(1))
.expect("short deterministic timeout");
let migration = Migration::unapplied("V1__init", "CREATE TABLE data (id INTEGER);")
.expect("valid migration");
Runner::new(&[migration])
.run(&mut contender)
.expect_err("held writer must make the migration busy")
}

#[test]
fn busy_migration_error_maps_to_retryable_wallet_storage_message() {
let upstream =
platform_wallet_storage::WalletStorageError::Migration(busy_migration_error());
let err = TaskError::from_wallet_storage_open_error(upstream);
assert!(
matches!(err, TaskError::WalletStorageInUse { .. }),
"Expected WalletStorageInUse, got: {err:?}"
);
let message = err.to_string();
assert!(message.contains("Close") && message.contains("try again"));
assert!(!message.contains("incompatible"));
assert!(std::error::Error::source(&err).is_some());
}

/// A divergent migration history (database written under an
/// incompatible storage layout) maps to the dedicated
/// `WalletDataIncompatible` variant. Its `Display` tells the user to
/// remove the local wallet data, NOT the misleading "free disk space" copy.
/// `WalletDataIncompatible` variant. Its `Display` must never lead the user
/// to destroy the secrets vault, and must not blame disk space.
#[test]
fn migration_error_maps_to_wallet_data_incompatible() {
let upstream =
Expand All @@ -5826,9 +5914,18 @@ mod tests {
);

let msg = err.to_string();
assert!(msg.contains(".sqlite-wal") && msg.contains(".sqlite-shm"));
assert!(msg.contains("Close all") && msg.contains("write down each imported private key"));
assert!(
msg.contains("write down the recovery phrase")
&& msg.contains(".sqlite")
&& msg.contains("leave the secrets folder where it is"),
"Expected scoped, vault-preserving guidance, got: {msg}"
);
let lower = msg.to_lowercase();
assert!(
msg.contains("not compatible") && msg.contains("Remove"),
"Expected incompatibility guidance, got: {msg}"
!lower.contains("remove") && !lower.contains("delete"),
"Incompatible-data guidance must not tell the user to delete anything, got: {msg}"
);
assert!(
!msg.contains("disk space"),
Expand Down
26 changes: 26 additions & 0 deletions src/backend_task/identity/remove_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,32 @@ mod tests {
.any(|key_id| view.get(&MAIN, *key_id).unwrap().is_some())
}

/// Upgrade backups copy the network database with every identity in it, so a
/// removed identity must not survive in them.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn removing_an_identity_deletes_upgrade_backups() {
let staged = stage_identity_with_vaulted_keys(HIGH, MEDIUM).await;
let database = crate::wallet_backend::wallet_database_path(
staged.ctx.data_dir(),
staged.ctx.network(),
);
let backup = database.with_file_name(format!(
"{}.platform-67d4ef3-backup-fixture.sqlite",
database.file_name().unwrap().to_string_lossy()
));
std::fs::write(&backup, b"old identity rows").unwrap();

staged
.ctx
.remove_identity(staged.id)
.expect("the identity must be removed");

assert!(
!backup.exists(),
"the upgrade backup must be deleted together with the identity"
);
}

/// A removal that fails before the identity is delisted did not happen:
/// the identity is still on every screen, its keys are still on the
/// device, and the button that started this still works. Reporting it as a
Expand Down
34 changes: 27 additions & 7 deletions src/backend_task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,16 @@ fn identity_load_ticket(task: &BackendTask) -> Option<(Identifier, IdentityLoadT
}

/// Whether a wallet-backend build error is terminal (storage written by a
/// newer/incompatible app build). These must surface their actionable
/// message instead of being logged-and-discarded as a transient deferral
/// (F50); every other init error is retried by the cold-boot bridge.
/// newer/incompatible app build, or a compatibility upgrade that fails the
/// same way on every attempt). These must surface their actionable message
/// instead of being logged-and-discarded as a transient deferral (F50); every
/// other init error is retried by the cold-boot bridge.
pub(crate) fn is_terminal_storage_open_error(error: &TaskError) -> bool {
matches!(
error,
TaskError::WalletDataTooNew { .. } | TaskError::WalletDataIncompatible { .. }
)
match error {
TaskError::WalletDataTooNew { .. } | TaskError::WalletDataIncompatible { .. } => true,
TaskError::PlatformDatabaseUpgrade { source } => !source.is_retryable(),
_ => false,
}
Comment thread
lklimek marked this conversation as resolved.
Resolved
}
Comment on lines 238 to 246

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SEC-004 · MEDIUM — WalletStorageInUse is non-terminal, so its "close the other session" message is logged-and-discarded on the wallet-task path

WalletStorageInUse's nice 'close the other session' message gets logged and thrown away on the ensure_wallet_backend path — the one place someone running two instances would actually see it. Wire it into the terminal-error list, or it's dead code with good intentions.


/// Information about fees for a platform state transition.
Expand Down Expand Up @@ -2331,6 +2333,24 @@ mod tests {
)),
}
));
use crate::wallet_backend::platform_compatibility::UpgradeError;
for source in [
UpgradeError::Unrecognized,
UpgradeError::IdentityRoster { source: None },
UpgradeError::Verification,
UpgradeError::TypedValidation(Box::new(std::io::Error::other("fixture"))),
] {
assert!(
is_terminal_storage_open_error(&TaskError::PlatformDatabaseUpgrade { source }),
"a deterministic upgrade failure must surface instead of re-running"
);
}
// A user-clearable upgrade failure keeps offering a retry.
assert!(!is_terminal_storage_open_error(
&TaskError::PlatformDatabaseUpgrade {
source: UpgradeError::from(std::io::Error::from(std::io::ErrorKind::StorageFull)),
}
));
// A transient pre-wire state must NOT be treated as terminal.
assert!(!is_terminal_storage_open_error(
&TaskError::WalletBackendNotYetWired
Expand Down
Loading
Loading