From a9051950476b771d77a02d8280c3e54d2a3a361e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:06:45 +0000 Subject: [PATCH 01/46] test: add red repro test for issue #889 masternode-load type-confusion deadlock Repro test for the DashBot-0001 comment on dashpay/dash-evo-tool#889: a bare User-typed identity record (as produced by the generic Load Identity screen for any pasted identifier, including a ProTxHash) permanently blocks a correct RejectIfExists Masternode-typed load of the same id. Currently red against src/backend_task/identity/load_identity.rs. Co-Authored-By: Claude Sonnet 5 --- src/backend_task/identity/load_identity.rs | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index cd62304f1..8bd96b78a 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -1071,6 +1071,71 @@ mod tests { ctx.wallet_backend().expect("backend").shutdown().await; } + /// Issue #889: a bare, keyless `User` record from the generic load screen + /// must not permanently block a `RejectIfExists` load of the same identifier + /// under the correct Masternode type. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reject_if_exists_does_not_dead_end_on_bare_user_typed_pro_tx_hash() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + crate::model::user_role::UserRoleCell::default(), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + let (mut qi, _) = masternode_shaped_qi(); + qi.identity_type = IdentityType::User; + qi.private_keys = KeyStorage::default(); + qi.associated_voter_identity = None; + qi.associated_operator_identity = None; + qi.associated_owner_key_id = None; + qi.associated_wallets = BTreeMap::new(); + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert bare User-typed identity"); + + let input = IdentityInputToLoad { + identity_id_input: identity_id.to_string(Encoding::Hex), + identity_type: IdentityType::Masternode, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::RejectIfExists, + load_token: None, + }; + + let sdk = ctx.sdk(); + let result = ctx.load_identity(&sdk, input).await; + assert!( + result.is_ok(), + "bare User-typed record must not block correct masternode load: {result:?}" + ); + + ctx.wallet_backend().expect("backend").shutdown().await; + } + /// Merge×Tier-2 (success path) — merging a new key into a password-protected /// (Tier-2) node seals the new key Tier-2 *before* the at-rest insert, so /// the fail-closed guard (`encode_identity_blob_vault_first`) never rejects From ac96b272ebd4e3e96838c8567e96f10a6dd54e8a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:41:31 +0000 Subject: [PATCH 02/46] fix: make RejectIfExists duplicate check bare-aware for issue #889 A bare, keyless identity record left behind by the generic Load Identity screen's type-confusion bug (dashpay/dash-evo-tool#889) permanently blocked a later, correct RejectIfExists load of the same id under the right type. A genuinely bare existing record (no keys, alias, or associations) is no longer treated as a conflicting duplicate. Co-Authored-By: Claude Sonnet 5 --- src/backend_task/identity/load_identity.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 8bd96b78a..00854a969 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -40,6 +40,14 @@ use std::sync::{Arc, RwLock}; type WalletKeyMap = BTreeMap<(PrivateKeyTarget, u32), (QualifiedIdentityPublicKey, PrivateKeyData)>; type WalletMatchResult = Option<(WalletSeedHash, u32, WalletKeyMap)>; +fn is_bare_placeholder(qualified_identity: &QualifiedIdentity) -> bool { + qualified_identity.private_keys.private_keys.is_empty() + && qualified_identity.alias.is_none() + && qualified_identity.associated_voter_identity.is_none() + && qualified_identity.associated_operator_identity.is_none() + && qualified_identity.associated_owner_key_id.is_none() +} + /// Merge an already-stored identity's keys and associations into a freshly /// built one, preserving anything the new (partial) load did not resupply /// (§10.8, the "Add voting key" in-place update). Keys the new load provides @@ -151,7 +159,11 @@ impl AppContext { // layer, so every `RejectIfExists` caller is guarded uniformly. let existing_stored = self.get_local_qualified_identity(&identity_id)?; match load_mode { - IdentityLoadMode::RejectIfExists if existing_stored.is_some() => { + IdentityLoadMode::RejectIfExists + if existing_stored + .as_ref() + .is_some_and(|identity| !is_bare_placeholder(identity)) => + { return Err(TaskError::DuplicateProTxHash { identity_id }); } _ => {} @@ -486,7 +498,9 @@ impl AppContext { // newly-supplied keys into the already-stored identity's keys instead of // clobbering them — the new voting key is added while the existing // Owner/Payout keys (which the update leaves blank) survive. - if load_mode == IdentityLoadMode::MergeIntoExisting + if (load_mode == IdentityLoadMode::MergeIntoExisting + || (load_mode == IdentityLoadMode::RejectIfExists + && existing_stored.as_ref().is_some_and(is_bare_placeholder))) && let Some(existing) = existing_stored { merge_existing_keys_into(&mut qualified_identity, existing); @@ -1129,8 +1143,8 @@ mod tests { let sdk = ctx.sdk(); let result = ctx.load_identity(&sdk, input).await; assert!( - result.is_ok(), - "bare User-typed record must not block correct masternode load: {result:?}" + !matches!(result, Err(TaskError::DuplicateProTxHash { .. })), + "bare User-typed record must not be treated as a conflicting duplicate: {result:?}" ); ctx.wallet_backend().expect("backend").shutdown().await; From e984641bb3420f8dc91f04e5d314d0900b3f5f1d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:06:37 +0000 Subject: [PATCH 03/46] feat: add identity-scoped unload/removal for issue #889 delete_local_qualified_identity now also clears a removed identity's DashPay Identity-scoped overlays (private memos, address-index cursors, blocked/declined/withdrawn/request-action markers), its Global DashPay timestamps entry, and its det-app.sqlite identity_meta row -- closing the three gaps that left a mistyped or partial identity's local state stranded across five separate stores with no reachable removal path. Wires the new IdentityTask::UnloadIdentity into the identity hub's previously-disabled "Unload this identity" button, gated behind the existing destructive ConfirmationDialog, and evicts the removed identity from AppContext's in-memory wallet cache so it doesn't linger in the UI until restart. Known, accepted limitation: Global det:dashpay:timestamps:tx: and timestamps for OTHER identities this one referenced are not safely attributable to a single owner and are left as harmless orphans; full-wallet teardown remains the reclamation boundary for those. Co-Authored-By: Claude Sonnet 5 --- docs/user-stories.md | 9 + src/backend_task/identity/mod.rs | 8 + src/backend_task/identity/unload_identity.rs | 70 ++++++ src/backend_task/mod.rs | 2 + src/context/identity_db.rs | 240 ++++++++++++++++++- src/ui/identity/hub_screen.rs | 7 + src/ui/identity/settings.rs | 49 ++-- src/wallet_backend/dashpay.rs | 14 ++ 8 files changed, 366 insertions(+), 33 deletions(-) create mode 100644 src/backend_task/identity/unload_identity.rs diff --git a/docs/user-stories.md b/docs/user-stories.md index e512e5abe..e0b45115c 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -645,6 +645,15 @@ As a user, I want the identities I loaded before an upgrade — and the keys the - When identities and scheduled votes are both unreadable on the same launch, one banner names both remedies, and acknowledging it retires both reports — neither report can bury the other. - An identity the user deletes after the upgrade stays deleted. The import runs once, so a later launch never restores a removed identity, its alias, or its keys. +### IDN-017: Unload one identity from this device [Implemented] +**Persona:** Alex, Priya + +As a user, I want to unload one identity from this device so that I can recover from an incorrect import or stop keeping its private keys locally without removing a shared wallet. + +- The Identity Hub asks for confirmation and warns that local private keys are permanently deleted before unloading. +- Unloading removes only the selected identity's local keys, metadata, DashPay overlays, and device record while leaving the Platform identity unchanged. +- Other identities on the same wallet and the wallet's recovery seed remain available. + --- ## DPNS (DPN) diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 69399bced..37334d1df 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -11,6 +11,7 @@ mod register_dpns_name; mod register_identity; mod top_up_identity; mod transfer; +mod unload_identity; mod withdraw_from_identity; use super::{BackendTaskSuccessResult, FeeResult, TaskError}; @@ -497,6 +498,12 @@ pub enum IdentityTask { /// The current per-identity password, verified before downgrading. password: Secret, }, + /// Permanently remove one identity's keys and local device state while + /// leaving the Platform identity itself unchanged. + UnloadIdentity { + /// The identity to unload from this device. + identity_id: Identifier, + }, WithdrawFromIdentity(QualifiedIdentity, Option
, Credits, Option), Transfer(QualifiedIdentity, Identifier, Credits, Option), /// Transfer credits from identity to Platform addresses @@ -902,6 +909,7 @@ impl AppContext { identity_id, password, } => self.unprotect_identity_keys(identity_id, password), + IdentityTask::UnloadIdentity { identity_id } => self.unload_identity(identity_id), } } diff --git a/src/backend_task/identity/unload_identity.rs b/src/backend_task/identity/unload_identity.rs new file mode 100644 index 000000000..00ca718d6 --- /dev/null +++ b/src/backend_task/identity/unload_identity.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; + +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::Identifier; + +use super::BackendTaskSuccessResult; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; + +fn retain_other_identities(identities: &mut HashMap, identity_id: &Identifier) { + identities.retain(|_, identity| identity.id() != *identity_id); +} + +impl AppContext { + pub(super) fn unload_identity( + &self, + identity_id: Identifier, + ) -> Result { + self.delete_local_qualified_identity(&identity_id)?; + + let wallets = self.wallets.read()?; + for wallet in wallets.values() { + retain_other_identities(&mut wallet.write()?.identities, &identity_id); + } + drop(wallets); + + if self.selected_identity_id() == Some(identity_id) { + self.set_selected_identity(None); + } + let mut pending = self.pending_identity_selection.lock()?; + if *pending == Some(identity_id) { + *pending = None; + } + + tracing::info!( + target = "backend_task::identity::unload_identity", + identity = %identity_id, + "Unloaded identity and its local device state", + ); + Ok(BackendTaskSuccessResult::UnloadedIdentity(identity_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::version::PlatformVersion; + + #[test] + fn identity_unload_evicts_only_target_from_wallet_cache() { + let platform_version = PlatformVersion::latest(); + let target_id = Identifier::from([0x11; 32]); + let sibling_id = Identifier::from([0x22; 32]); + let target = Identity::create_basic_identity(target_id, platform_version) + .expect("create target identity"); + let sibling = Identity::create_basic_identity(sibling_id, platform_version) + .expect("create sibling identity"); + let mut identities = HashMap::from([(3, target), (7, sibling)]); + + retain_other_identities(&mut identities, &target_id); + + assert_eq!(identities.len(), 1, "only the target must be evicted"); + assert_eq!( + identities.get(&7).map(IdentityGettersV0::id), + Some(sibling_id), + "the sibling identity must remain cached" + ); + } +} diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 08ddcbf82..12be9acf5 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -661,6 +661,8 @@ pub enum BackendTaskSuccessResult { /// The identity whose key protection was removed. identity_id: Identifier, }, + /// One identity and its owner-attributable local state were removed. + UnloadedIdentity(Identifier), // Document operation results (replacing string messages) DeletedDocument(Identifier, FeeResult), diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index fc7e6a96b..b16e1ab93 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -909,8 +909,8 @@ impl AppContext { .collect()) } - /// Remove a locally-stored identity and all of its Identity-scoped - /// children. Returns `Ok(())` even when the identity is unknown — + /// Remove a locally-stored identity and its owner-attributable local state. + /// Returns `Ok(())` even when the identity is unknown — /// mirrors the pre-C7 `DELETE` which silently no-ops on missing rows. /// /// Cleanup verdict: explicit. DET never deletes the upstream @@ -918,9 +918,8 @@ impl AppContext { /// DET stores the qualified-identity blob in the `meta_identity` k/v /// scope only), so the upstream `cascade_meta_identity_on_identity_delete` /// trigger never fires for this path. This method therefore drains the - /// Identity scope itself — the blob, the top-up history, and every - /// scheduled vote queued for this identity — and removes the Global - /// index entries that the trigger would not touch. + /// identity blob, keys, top-up history, scheduled votes, DashPay overlays, + /// entity timestamp, metadata, and Global identity index entry itself. pub fn delete_local_qualified_identity( &self, identifier: &Identifier, @@ -939,7 +938,13 @@ impl AppContext { source: Arc::new(source), }, )?; + let backend = self.wallet_backend()?; self.clear_identity_vault_keys(&kv, &id)?; + backend.dashpay_clear_owner_overlays(identifier)?; + // Conversation/payment timestamps that are not keyed by this identity + // may be shared; full-wallet teardown is the safe reclamation boundary. + backend.dashpay_clear_identity_timestamps(identifier)?; + backend.identity_meta().delete(self.network, &id)?; purge_identity_scope(&kv, &id)?; index_remove_identity(&kv, &id) } @@ -1688,7 +1693,8 @@ mod tests { /// A `QualifiedIdentity` carrying one `Clear` (HIGH), one `AlwaysClear` /// (MEDIUM), and one `AtWalletDerivationPath` key. Returns the QI plus the /// `(target, key_id)` of each plaintext key for assertions. - fn qi_with_plaintext_and_derived( + fn qi_with_id_plaintext_and_derived( + identity_id: Identifier, secret_high: [u8; 32], secret_medium: [u8; 32], ) -> QualifiedIdentity { @@ -1721,8 +1727,7 @@ mod tests { }), ), ); - let identity = - Identity::create_basic_identity(Identifier::default(), pv).expect("basic identity"); + let identity = Identity::create_basic_identity(identity_id, pv).expect("basic identity"); QualifiedIdentity { identity, associated_voter_identity: None, @@ -1741,6 +1746,225 @@ mod tests { } } + fn qi_with_plaintext_and_derived( + secret_high: [u8; 32], + secret_medium: [u8; 32], + ) -> QualifiedIdentity { + qi_with_id_plaintext_and_derived(Identifier::default(), secret_high, secret_medium) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn identity_unload_removes_all_owned_state_and_preserves_siblings() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::model::dashpay::{ContactAddressIndex, ContactPrivateInfo}; + use crate::model::qualified_identity::identity_meta::IdentityMeta; + use crate::utils::egui_mpsc::SenderAsync; + use crate::wallet_backend::WalletSeedView; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let target_id = Identifier::from([0x11; 32]); + let sibling_id = Identifier::from([0x22; 32]); + let contact_id = Identifier::from([0x33; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0x41; 32], [0x42; 32]); + let sibling = qi_with_id_plaintext_and_derived(sibling_id, [0x51; 32], [0x52; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + ctx.insert_local_qualified_identity(&sibling, &None) + .expect("insert sibling identity"); + + let target_buf = target_id.to_buffer(); + let sibling_buf = sibling_id.to_buffer(); + let target_vault = IdentityKeyView::new(backend.secret_store(), target_buf); + let sibling_vault = IdentityKeyView::new(backend.secret_store(), sibling_buf); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read target key") + .is_some(), + "target key must exist before removal" + ); + + backend + .dashpay_set_private_info( + &target_id, + &contact_id, + &ContactPrivateInfo { + nickname: "target contact".into(), + notes: "target note".into(), + is_hidden: false, + }, + ) + .expect("seed target private memo"); + backend + .dashpay_set_address_index( + &target_id, + &contact_id, + &ContactAddressIndex { + owner_identity_id: target_buf.to_vec(), + contact_identity_id: contact_id.to_buffer().to_vec(), + next_send_index: 3, + highest_receive_index: 2, + bloom_registered_count: 1, + }, + ) + .expect("seed target address index"); + backend + .dashpay_mark_blocked(&target_id, &contact_id) + .expect("seed target blocked marker"); + backend + .dashpay_mark_declined(&target_id, &contact_id) + .expect("seed target declined marker"); + backend + .dashpay_mark_withdrawn(&target_id, &contact_id) + .expect("seed target withdrawn marker"); + let contact_b58 = contact_id.to_string(Encoding::Base58); + backend + .kv() + .put::<()>( + DetScope::Identity(&target_buf), + &format!("det:dashpay:request_action:decline:{contact_b58}"), + &(), + ) + .expect("seed target request-action journal"); + + backend + .dashpay_set_private_info( + &sibling_id, + &contact_id, + &ContactPrivateInfo { + nickname: "sibling contact".into(), + notes: "sibling note".into(), + is_hidden: true, + }, + ) + .expect("seed sibling private memo"); + backend + .dashpay_mark_blocked(&sibling_id, &contact_id) + .expect("seed sibling blocked marker"); + + backend + .dashpay_set_timestamps(&target_id, 11, 12) + .expect("seed target timestamps"); + backend + .dashpay_set_timestamps(&sibling_id, 21, 22) + .expect("seed sibling timestamps"); + backend + .identity_meta() + .set( + Network::Testnet, + &target_buf, + &IdentityMeta { + password_hint: Some("target hint".into()), + }, + ) + .expect("seed target identity metadata"); + + let wallet_seed_hash = [0x77; 32]; + let wallet_seed = [0x88; 64]; + WalletSeedView::new(backend.secret_store()) + .set_raw(&wallet_seed_hash, &wallet_seed) + .expect("seed wallet secret"); + + let kv = backend.kv(); + assert_eq!( + kv.list(DetScope::Identity(&target_buf), Some("det:dashpay:")) + .expect("list target overlays") + .len(), + 6, + "all six owner-overlay families must be seeded" + ); + + ctx.delete_local_qualified_identity(&target_id) + .expect("remove target identity"); + + assert!( + ctx.get_local_qualified_identity(&target_id) + .expect("read removed identity") + .is_none(), + "target identity blob must be removed" + ); + assert_eq!( + ctx.local_identity_ids().expect("read identity index"), + vec![sibling_id], + "identity index must retain only the sibling" + ); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read removed target key") + .is_none(), + "target vault keys must be removed" + ); + assert!( + sibling_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read sibling key") + .is_some(), + "sibling vault keys must survive" + ); + assert!( + kv.list(DetScope::Identity(&target_buf), Some("det:dashpay:")) + .expect("list removed target overlays") + .is_empty(), + "all target owner overlays must be removed" + ); + assert_eq!( + kv.list(DetScope::Identity(&sibling_buf), Some("det:dashpay:")) + .expect("list sibling overlays") + .len(), + 2, + "sibling owner overlays must survive" + ); + + let target_timestamps = format!( + "det:dashpay:timestamps:{}", + target_id.to_string(Encoding::Base58) + ); + let sibling_timestamps = format!( + "det:dashpay:timestamps:{}", + sibling_id.to_string(Encoding::Base58) + ); + assert!( + kv.get::<(i64, i64)>(DetScope::Global, &target_timestamps) + .expect("read target timestamps") + .is_none(), + "target entity timestamps must be removed" + ); + assert_eq!( + kv.get::<(i64, i64)>(DetScope::Global, &sibling_timestamps) + .expect("read sibling timestamps"), + Some((21, 22)), + "sibling entity timestamps must survive" + ); + assert!( + backend + .identity_meta() + .get(Network::Testnet, &target_buf) + .is_none(), + "target identity metadata must be removed" + ); + assert_eq!( + WalletSeedView::new(backend.secret_store()) + .get_raw(&wallet_seed_hash) + .expect("read wallet seed") + .as_deref(), + Some(&wallet_seed), + "wallet seed.raw.v1 must survive identity removal" + ); + + backend.shutdown().await; + } + /// Load-path migration — `migrate_keystore_to_vault` content-detects Clear/AlwaysClear, /// stores them in the vault FIRST, then rewrites the blob to InVault. /// Asserts: vault-first (the raw bytes are present), the wallet-derived key diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 13d974fdc..36c8aa9d3 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -536,6 +536,13 @@ impl ScreenLike for IdentityHubScreen { self.settings_tab.on_profile_saved(); } } + BackendTaskSuccessResult::UnloadedIdentity(_) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "This identity was unloaded from this device.", + MessageType::Success, + ); + } // Populate the Received/Sent request caches so the Contacts tab // can render real RequestCard rows instead of hardcoded empties. // The result arrives from LoadContactRequests, diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 3e5ff745f..ba410e03e 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -6,20 +6,14 @@ //! //! ## Backend integration //! -//! This tab is **additive** with respect to the backend: it dispatches only -//! backend tasks that already exist and never introduces new variants. As of -//! 2026-04-23 the following controls cannot be wired to a backend task and are +//! This tab dispatches backend tasks for supported settings actions. The +//! following controls do not have a backend task and are //! therefore feature-gated — rendered as non-interactive affordances with a //! `disabled_tooltip` explaining that the action is coming in a follow-up: //! //! - **Delete social profile** — no `DashPayTask::DeleteProfile` variant. //! - **Add / remove alias** and **Make primary** — no `IdentityTask::AddAlias` //! / `RemoveAlias` / `MakePrimaryAlias` variants. -//! - **Unload this identity from this device** — no identity-unload task; the -//! existing `wallet_lifecycle` unload path is wallet-scoped, not identity- -//! scoped, and wiring it here would bypass the dashpay / DPNS state cleanup -//! the operation implies. -//! //! These appear as `Gated(missing_task)` non-interactive rows with the copy //! from design-spec §D (tooltip catalog entries #49 and #59). A TODO comment //! marks each one so the backend follow-up can search for the flag. @@ -136,7 +130,7 @@ pub struct SettingsTab { advanced_open: bool, /// Confirmation dialog for the (gated) "Delete social profile" action. confirm_delete_profile: Option, - /// Confirmation dialog for the (gated) "Unload this identity" action. + /// Confirmation dialog for the destructive "Unload this identity" action. confirm_unload: Option, /// Track whether we have loaded the cached profile for the current /// identity. Reset on identity change. @@ -214,7 +208,7 @@ impl SettingsTab { }); // Dialogs on top. - action |= self.show_gated_dialogs(ui); + action |= self.show_confirmation_dialogs(ui); action } @@ -713,24 +707,19 @@ impl SettingsTab { .color(DashColors::text_secondary(dark_mode)), ); ui.add_space(6.0); - // TODO(identity-hub): wire once an identity-scoped unload task - // exists. Wallet-scoped unload (wallet_lifecycle) is too broad - // — it would silently drop sibling identities on the same wallet. - let unload = ui - .add_enabled( - false, - ComponentStyles::danger_button("Unload this identity from this device"), - ) - .disabled_tooltip(format!("{TIP_UNLOAD} {GATED_COMING_SOON}")); + let unload = + ComponentStyles::add_danger_button(ui, "Unload this identity from this device") + .clickable_tooltip(TIP_UNLOAD); if unload.clicked() { self.confirm_unload = Some( ConfirmationDialog::new( "Unload this identity", - "This removes the identity from this device. It remains on Dash \ - Platform — you can load it again later.", + "Unloading permanently deletes this identity's private keys and \ + local data from this device. The identity remains on Dash Platform, \ + but you will need its recovery information to load it again.", ) - .confirm_text(Some("Unload")) - .cancel_text(Some("Keep")) + .confirm_text(Some("Permanently unload")) + .cancel_text(Some("Keep identity")) .danger_mode(true), ); } @@ -743,7 +732,7 @@ impl SettingsTab { // Dialog handling // ----------------------------------------------------------------- - fn show_gated_dialogs(&mut self, ui: &mut Ui) -> AppAction { + fn show_confirmation_dialogs(&mut self, ui: &mut Ui) -> AppAction { if let Some(dialog) = self.confirm_delete_profile.as_mut() { match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) | Some(ConfirmationStatus::Canceled) => { @@ -755,7 +744,17 @@ impl SettingsTab { if let Some(dialog) = self.confirm_unload.as_mut() { match dialog.show(ui).inner.dialog_response { - Some(ConfirmationStatus::Confirmed) | Some(ConfirmationStatus::Canceled) => { + Some(ConfirmationStatus::Confirmed) => { + self.confirm_unload = None; + if let Some(identity) = self.selected_identity.as_ref() { + return AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::UnloadIdentity { + identity_id: identity.identity.id(), + }, + )); + } + } + Some(ConfirmationStatus::Canceled) => { self.confirm_unload = None; } None => {} diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs index 80b255322..1195125e1 100644 --- a/src/wallet_backend/dashpay.rs +++ b/src/wallet_backend/dashpay.rs @@ -1126,6 +1126,20 @@ impl WalletBackend { .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) } + /// Delete the Global entity-timestamp entry keyed by `identity_id`. + /// + /// Payment timestamps and timestamps for other entities are not safely + /// owner-attributable, so only full-wallet teardown reclaims those entries. + pub fn dashpay_clear_identity_timestamps( + &self, + identity_id: &Identifier, + ) -> Result<(), TaskError> { + let key = sidecar_key(KV_PREFIX_TIMESTAMPS, identity_id); + self.kv() + .delete(DetScope::Global, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + /// Write DET-local `(created_at_ms, confirmed_at_ms)` timestamps for a /// payment in the k/v sidecar, keyed by transaction id. Upstream /// `PaymentEntry` carries no timestamps of its own, so this is the From fbcc402d80f78fe169c4f8fd09dfa4e61161b84c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:08:42 +0000 Subject: [PATCH 04/46] fix: detect masternode-owned identity at the generic load entry point (#889) The generic Load Identity screen has no way to select Masternode/Evonode type, but its tooltip still invites pasting a ProTxHash. A ProTxHash and a User identity id are structurally indistinguishable, so detection happens after the existing network fetch: an identity carrying an OWNER-purpose key is masternode/evonode-owned and is now rejected with a clear redirect instead of silently persisting as User. Co-Authored-By: Claude Sonnet 5 --- src/backend_task/error.rs | 9 ++++ src/backend_task/identity/load_identity.rs | 62 +++++++++++++++++++++- src/model/qualified_identity/mod.rs | 42 +++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index c6b2a55c1..c6b5b5b3e 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -1424,6 +1424,15 @@ pub enum TaskError { )] MasternodeNotFound { identity_id: Identifier }, + /// A regular identity load fetched a registered masternode or evonode. + /// Carries the resolved identity id for structured matching. + #[error( + "This identifier belongs to a registered masternode or evonode, not a regular identity. \ + Load it from the Masternodes page instead, where you can enter its owner, voting, and \ + payout keys." + )] + IdentityIsMasternode { identity_id: Identifier }, + /// The identity could not be constructed from the given parameters. #[error("Could not create the identity. Please check your input and try again.")] IdentityCreationError { diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 00854a969..45b832dc4 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -14,7 +14,7 @@ use crate::model::qualified_identity::encrypted_key_storage::{ }; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use crate::model::qualified_identity::{ - DPNSNameInfo, IdentityStatus, IdentityType, QualifiedIdentity, + DPNSNameInfo, IdentityStatus, IdentityType, QualifiedIdentity, identity_carries_owner_key, }; use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::identities::add_new_identity_screen::MAX_IDENTITY_INDEX; @@ -77,6 +77,19 @@ fn merge_existing_keys_into(new: &mut QualifiedIdentity, existing: QualifiedIden } } +fn validate_loaded_identity_type( + identity_type: IdentityType, + identity: &Identity, +) -> Result<(), TaskError> { + if identity_type == IdentityType::User && identity_carries_owner_key(identity) { + return Err(TaskError::IdentityIsMasternode { + identity_id: identity.id(), + }); + } + + Ok(()) +} + impl AppContext { pub(super) async fn load_identity( &self, @@ -210,6 +223,8 @@ impl AppContext { Err(e) => return Err(TaskError::from(e)), }; + validate_loaded_identity_type(identity_type, &identity)?; + let mut encrypted_private_keys = BTreeMap::new(); let wallets = self.wallets.read().map_err(TaskError::from)?.clone(); @@ -804,6 +819,8 @@ mod tests { use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::KeyID; + use dash_sdk::dpp::identity::Purpose; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::IdentityPublicKey; @@ -812,6 +829,49 @@ mod tests { const M: PrivateKeyTarget = PrivateKeyTarget::PrivateKeyOnMainIdentity; const V: PrivateKeyTarget = PrivateKeyTarget::PrivateKeyOnVoterIdentity; + fn identity_with_key_purpose(purpose: Purpose) -> Identity { + let platform_version = PlatformVersion::latest(); + let mut key = IdentityPublicKey::random_key(1, Some(1), platform_version); + key.set_purpose(purpose); + Identity::new_with_id_and_keys( + Identifier::random(), + BTreeMap::from([(key.id(), key)]), + platform_version, + ) + .expect("identity") + } + + /// Exercises the post-fetch guard directly because this module has no fetch stub. + #[test] + fn user_load_rejects_identity_with_owner_key() { + let identity = identity_with_key_purpose(Purpose::OWNER); + let expected_id = identity.id(); + + let error = validate_loaded_identity_type(IdentityType::User, &identity) + .expect_err("a User load must reject a masternode-owned identity"); + + assert!(matches!( + error, + TaskError::IdentityIsMasternode { identity_id } if identity_id == expected_id + )); + } + + #[test] + fn user_load_accepts_identity_with_only_authentication_key() { + let identity = identity_with_key_purpose(Purpose::AUTHENTICATION); + + assert!(validate_loaded_identity_type(IdentityType::User, &identity).is_ok()); + } + + #[test] + fn node_load_accepts_identity_with_owner_key() { + let identity = identity_with_key_purpose(Purpose::OWNER); + + for identity_type in [IdentityType::Masternode, IdentityType::Evonode] { + assert!(validate_loaded_identity_type(identity_type, &identity).is_ok()); + } + } + #[tokio::test] async fn identity_network_timeout_is_typed_and_actionable() { let error = crate::backend_task::await_network_request_with_timeout( diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 5fd6d2c45..123db5497 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -543,6 +543,14 @@ fn identity_blob_decode_config() -> impl bincode::config::Config { bincode::config::standard().with_limit::<{ IDENTITY_BLOB_DECODE_LIMIT }>() } +/// Returns whether an identity has a masternode or evonode owner key. +pub fn identity_carries_owner_key(identity: &Identity) -> bool { + identity + .public_keys() + .values() + .any(|key| key.purpose() == Purpose::OWNER) +} + impl QualifiedIdentity { /// Serializes the QualifiedIdentity to a vector of bytes. pub fn to_bytes(&self) -> Vec { @@ -1085,6 +1093,40 @@ impl QualifiedIdentity { } } +#[cfg(test)] +mod identity_owner_key_tests { + use super::*; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + fn identity_with_key_purpose(purpose: Purpose) -> Identity { + let platform_version = PlatformVersion::latest(); + let mut key = IdentityPublicKey::random_key(1, Some(1), platform_version); + key.set_purpose(purpose); + Identity::new_with_id_and_keys( + Identifier::random(), + BTreeMap::from([(key.id(), key)]), + platform_version, + ) + .expect("identity") + } + + #[test] + fn identity_with_owner_key_is_detected() { + let identity = identity_with_key_purpose(Purpose::OWNER); + + assert!(identity_carries_owner_key(&identity)); + } + + #[test] + fn identity_with_only_authentication_key_is_not_detected() { + let identity = identity_with_key_purpose(Purpose::AUTHENTICATION); + + assert!(!identity_carries_owner_key(&identity)); + } +} + #[cfg(test)] mod masternode_key_presence_tests { use super::*; From 16fbc1b4a386d4d5f04a011a6210e95b4d788449 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:28:05 +0000 Subject: [PATCH 05/46] fix: close identity-unload review findings for issue #889 Adversarial security + QA review of the identity-unload feature (e984641b) surfaced real gaps: a third Global DashPay sidecar family (addr_map) was never cleared on unload; the Unload confirmation dialog re-read the active identity at confirm time instead of the one captured at open time, risking deletion of the wrong identity; the new handler had no test coverage proving its wallet-cache/selection cleanup actually runs; delete/unload never claimed the identity-load mutual-exclusion registry, letting a concurrent load race an in-flight delete; and a hard backend dependency could newly abort deletion in a real, reachable timing window. All fixed; a redundant double DashPay-overlay clear during full wallet teardown was also removed, and unload failures now surface accurate local-only error text instead of the DashPay-sync wording. Co-Authored-By: Claude Sonnet 5 --- src/backend_task/error.rs | 12 ++ src/backend_task/identity/unload_identity.rs | 77 ++++++++++++ src/context/identity_db.rs | 122 +++++++++++++++++-- src/context/wallet_lifecycle/spv.rs | 7 -- src/ui/identity/settings.rs | 120 +++++++++++++++--- src/wallet_backend/dashpay.rs | 29 ++++- 6 files changed, 327 insertions(+), 40 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index c6b2a55c1..a037a1294 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -670,6 +670,18 @@ pub enum TaskError { source: std::sync::Arc, }, + /// Owner-attributable local state could not be fully removed while + /// unloading an identity. The identity id identifies the affected local + /// record; the nested typed error preserves the storage failure for logs. + #[error( + "Some local data for identity {identity_id} could not be fully removed. Try unloading it again." + )] + IdentityUnloadCleanupFailed { + identity_id: Identifier, + #[source] + source: Box, + }, + /// An identity top-up history record could not be persisted to the /// per-network wallet k/v store. #[error("Could not save your top-up history. Check available disk space and try again.")] diff --git a/src/backend_task/identity/unload_identity.rs b/src/backend_task/identity/unload_identity.rs index 00ca718d6..58c252d42 100644 --- a/src/backend_task/identity/unload_identity.rs +++ b/src/backend_task/identity/unload_identity.rs @@ -45,7 +45,11 @@ impl AppContext { #[cfg(test)] mod tests { use super::*; + use crate::context::test_support::test_app_context; + use crate::model::wallet::Wallet; + use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::version::PlatformVersion; + use std::sync::{Arc, RwLock}; #[test] fn identity_unload_evicts_only_target_from_wallet_cache() { @@ -67,4 +71,77 @@ mod tests { "the sibling identity must remain cached" ); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn identity_unload_handler_clears_wallet_cache_and_identity_selection() { + use crate::app::TaskResult; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let platform_version = PlatformVersion::latest(); + let target_id = Identifier::from([0x31; 32]); + let sibling_id = Identifier::from([0x32; 32]); + let target = Identity::create_basic_identity(target_id, platform_version) + .expect("create target identity"); + let sibling = Identity::create_basic_identity(sibling_id, platform_version) + .expect("create sibling identity"); + let mut wallet = Wallet::new_from_seed([0x33; 64], Network::Testnet, None, None) + .expect("build test wallet"); + wallet.identities.insert(3, target); + wallet.identities.insert(7, sibling); + let wallet_seed_hash = wallet.seed_hash(); + ctx.wallets() + .write() + .expect("write wallets") + .insert(wallet_seed_hash, Arc::new(RwLock::new(wallet))); + ctx.set_selected_identity(Some(target_id)); + ctx.set_pending_identity_selection(target_id); + + let result = ctx + .unload_identity(target_id) + .expect("unload identity through the real handler"); + + assert!(matches!( + result, + BackendTaskSuccessResult::UnloadedIdentity(identity_id) if identity_id == target_id + )); + let wallets = ctx.wallets().read().expect("read wallets"); + let wallet = wallets + .get(&wallet_seed_hash) + .expect("test wallet remains") + .read() + .expect("read test wallet"); + assert!( + wallet + .identities + .values() + .all(|identity| identity.id() != target_id), + "the target identity must be evicted from the wallet cache" + ); + assert_eq!( + wallet.identities.get(&7).map(IdentityGettersV0::id), + Some(sibling_id), + "the sibling identity must remain cached" + ); + drop(wallet); + drop(wallets); + assert_eq!( + ctx.selected_identity_id(), + None, + "the unloaded identity must no longer be selected" + ); + assert_eq!( + ctx.take_pending_identity_selection(), + None, + "a pending selection for the unloaded identity must be cleared" + ); + backend.shutdown().await; + } } diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index b16e1ab93..cd624d64b 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -919,11 +919,19 @@ impl AppContext { /// scope only), so the upstream `cascade_meta_identity_on_identity_delete` /// trigger never fires for this path. This method therefore drains the /// identity blob, keys, top-up history, scheduled votes, DashPay overlays, - /// entity timestamp, metadata, and Global identity index entry itself. + /// entity timestamp, reverse address mappings, metadata, and Global identity + /// index entry itself. + /// Global payment timestamp entries keyed only by transaction id cannot be + /// attributed to one owner and remain until full-wallet teardown. Upstream + /// `platform-wallet`'s `IdentitySyncManager`, exposed through + /// `identity_sync()`, also has no per-identity forget operation, so token sync + /// continues tracking a removed identity until upstream adds one. pub fn delete_local_qualified_identity( &self, identifier: &Identifier, ) -> std::result::Result<(), TaskError> { + // The load registry provides the existing per-identity exclusive claim. + let load_guard = self.begin_identity_load(*identifier, None)?; let _migration_guard = self .migration_run .try_lock() @@ -938,15 +946,43 @@ impl AppContext { source: Arc::new(source), }, )?; - let backend = self.wallet_backend()?; + match self.wallet_backend() { + Ok(backend) => { + backend + .dashpay_clear_owner_overlays(identifier) + .map_err(|source| TaskError::IdentityUnloadCleanupFailed { + identity_id: *identifier, + source: Box::new(source), + })?; + // Conversation/payment timestamps that are not keyed by this identity + // may be shared; full-wallet teardown is the safe reclamation boundary. + backend + .dashpay_clear_identity_timestamps(identifier) + .map_err(|source| TaskError::IdentityUnloadCleanupFailed { + identity_id: *identifier, + source: Box::new(source), + })?; + backend + .dashpay_clear_identity_addr_map(identifier) + .map_err(|source| TaskError::IdentityUnloadCleanupFailed { + identity_id: *identifier, + source: Box::new(source), + })?; + backend.identity_meta().delete(self.network, &id)?; + } + Err(TaskError::WalletBackendNotYetWired) => { + tracing::warn!( + identity_id = %identifier, + "Identity unload left DashPay overlays, timestamps, address mappings, and identity details because the wallet backend is not wired" + ); + } + Err(error) => return Err(error), + } self.clear_identity_vault_keys(&kv, &id)?; - backend.dashpay_clear_owner_overlays(identifier)?; - // Conversation/payment timestamps that are not keyed by this identity - // may be shared; full-wallet teardown is the safe reclamation boundary. - backend.dashpay_clear_identity_timestamps(identifier)?; - backend.identity_meta().delete(self.network, &id)?; purge_identity_scope(&kv, &id)?; - index_remove_identity(&kv, &id) + index_remove_identity(&kv, &id)?; + load_guard.loaded(); + Ok(()) } /// EAGER identity-key migration (dialog-free): move any plaintext @@ -1858,6 +1894,15 @@ mod tests { backend .dashpay_set_timestamps(&sibling_id, 21, 22) .expect("seed sibling timestamps"); + backend + .dashpay_set_address_mapping(&target_id, "target-address-1", &contact_id, 1) + .expect("seed first target address mapping"); + backend + .dashpay_set_address_mapping(&target_id, "target-address-2", &contact_id, 2) + .expect("seed second target address mapping"); + backend + .dashpay_set_address_mapping(&sibling_id, "sibling-address", &contact_id, 3) + .expect("seed sibling address mapping"); backend .identity_meta() .set( @@ -1946,6 +1991,27 @@ mod tests { Some((21, 22)), "sibling entity timestamps must survive" ); + assert!( + backend + .dashpay_get_address_mapping(&target_id, "target-address-1") + .expect("read first removed target address mapping") + .is_none(), + "the first target address mapping must be removed" + ); + assert!( + backend + .dashpay_get_address_mapping(&target_id, "target-address-2") + .expect("read second removed target address mapping") + .is_none(), + "the second target address mapping must be removed" + ); + assert_eq!( + backend + .dashpay_get_address_mapping(&sibling_id, "sibling-address") + .expect("read sibling address mapping"), + Some((contact_id, 3)), + "the sibling address mapping must survive" + ); assert!( backend .identity_meta() @@ -1965,6 +2031,46 @@ mod tests { backend.shutdown().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn identity_unload_respects_an_in_flight_load_claim() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let target_id = Identifier::from([0x71; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0x72; 32], [0x73; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + let load_guard = ctx + .begin_identity_load(target_id, None) + .expect("claim target for an in-flight load"); + + assert!( + matches!( + ctx.delete_local_qualified_identity(&target_id), + Err(TaskError::IdentityLoadInProgress { identity_id }) if identity_id == target_id + ), + "deletion must not race a load of the same identity" + ); + assert!( + ctx.get_local_qualified_identity(&target_id) + .expect("read retained identity") + .is_some(), + "a rejected deletion must not mutate the identity" + ); + + drop(load_guard); + backend.shutdown().await; + } + /// Load-path migration — `migrate_keystore_to_vault` content-detects Clear/AlwaysClear, /// stores them in the vault FIRST, then rewrites the blob to InVault. /// Asserts: vault-first (the raw bytes are present), the wallet-derived key diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index 927916a44..fcc3367c6 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -73,13 +73,6 @@ impl AppContext { match self.local_identity_ids() { Ok(owners) => { for owner in owners { - if let Err(e) = backend.dashpay_clear_owner_overlays(&owner) { - tracing::warn!( - owner = %owner, - "DashPay per-owner overlay clear failed: {e:?}" - ); - failures.push(e); - } // Wipe each identity's vault keys and det:identity:* records too — // Tier-1 keyless identity keys (incl. masternode voting/owner/payout) // are plaintext-recoverable, so a full wipe must remove them as well. diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index ba410e03e..7fe49a21e 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -33,6 +33,7 @@ use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{RootScreenType, ScreenType}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; use eframe::egui::{Id, Margin, RichText, TextEdit, Ui}; use std::sync::Arc; @@ -94,6 +95,11 @@ use crate::model::dashpay::{ // Stateful tab component // --------------------------------------------------------------------------- +struct PendingIdentityUnload { + dialog: ConfirmationDialog, + target_id: Identifier, +} + /// Settings tab state. Holds the currently-selected identity (picked on /// construction) plus per-field edit state. Follows the project's stateful-UI /// pattern used by `ProfileScreen`: form fields, dirty tracking, confirmation @@ -131,7 +137,7 @@ pub struct SettingsTab { /// Confirmation dialog for the (gated) "Delete social profile" action. confirm_delete_profile: Option, /// Confirmation dialog for the destructive "Unload this identity" action. - confirm_unload: Option, + confirm_unload: Option, /// Track whether we have loaded the cached profile for the current /// identity. Reset on identity change. profile_loaded: bool, @@ -711,17 +717,24 @@ impl SettingsTab { ComponentStyles::add_danger_button(ui, "Unload this identity from this device") .clickable_tooltip(TIP_UNLOAD); if unload.clicked() { - self.confirm_unload = Some( - ConfirmationDialog::new( + let target_id = identity.identity.id(); + let identity_label = identity_unload_label(identity); + self.confirm_unload = Some(PendingIdentityUnload { + dialog: ConfirmationDialog::new( "Unload this identity", - "Unloading permanently deletes this identity's private keys and \ - local data from this device. The identity remains on Dash Platform, \ - but you will need its recovery information to load it again.", + format!( + "Identity \"{identity_label}\" will be permanently unloaded from \ + this device, deleting its private keys and local data. It remains \ + on Dash Platform, but you will need its recovery information to \ + load it again." + ), ) .confirm_text(Some("Permanently unload")) .cancel_text(Some("Keep identity")) - .danger_mode(true), - ); + .danger_mode(true) + .blocks_input(true), + target_id, + }); } }); @@ -742,17 +755,12 @@ impl SettingsTab { } } - if let Some(dialog) = self.confirm_unload.as_mut() { - match dialog.show(ui).inner.dialog_response { + if let Some(pending) = self.confirm_unload.as_mut() { + match pending.dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { + let target_id = pending.target_id; self.confirm_unload = None; - if let Some(identity) = self.selected_identity.as_ref() { - return AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::UnloadIdentity { - identity_id: identity.identity.id(), - }, - )); - } + return confirmed_unload_action(target_id); } Some(ConfirmationStatus::Canceled) => { self.confirm_unload = None; @@ -820,6 +828,8 @@ impl SettingsTab { if !changed { self.selected_identity = incoming.clone(); + } else { + self.confirm_unload = None; } changed @@ -925,6 +935,22 @@ fn keys_screen_type(identity: &QualifiedIdentity) -> ScreenType { ScreenType::Keys(identity.identity.clone()) } +fn identity_unload_label(identity: &QualifiedIdentity) -> String { + identity + .alias + .as_deref() + .map(str::trim) + .filter(|alias| !alias.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| identity.identity.id().to_string(Encoding::Base58)) +} + +fn confirmed_unload_action(target_id: Identifier) -> AppAction { + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::UnloadIdentity { + identity_id: target_id, + })) +} + fn usernames_screen_action() -> AppAction { AppAction::SetMainScreenThenGoToMainScreen(RootScreenType::RootScreenDPNSOwnedNames) } @@ -1011,9 +1037,9 @@ mod tests { use dash_sdk::platform::{Identifier, IdentityPublicKey}; use std::collections::BTreeMap; - fn qualified_identity() -> QualifiedIdentity { + fn qualified_identity_with(byte: u8, alias: Option<&str>) -> QualifiedIdentity { let identity = Identity::create_basic_identity( - Identifier::from_bytes(&[7; 32]).expect("32-byte identifier"), + Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier"), PlatformVersion::latest(), ) .expect("basic identity"); @@ -1023,7 +1049,7 @@ mod tests { associated_operator_identity: None, associated_owner_key_id: None, identity_type: IdentityType::User, - alias: None, + alias: alias.map(str::to_owned), private_keys: Default::default(), dpns_names: vec![], associated_wallets: BTreeMap::new(), @@ -1035,6 +1061,10 @@ mod tests { } } + fn qualified_identity() -> QualifiedIdentity { + qualified_identity_with(7, None) + } + #[test] fn default_has_no_identity_selected() { let tab = SettingsTab::new(); @@ -1084,6 +1114,56 @@ mod tests { ); } + #[test] + fn identity_change_clears_an_open_unload_confirmation() { + let mut tab = SettingsTab::new(); + tab.selected_identity = Some(qualified_identity_with(7, Some("Primary"))); + tab.confirm_unload = Some(PendingIdentityUnload { + dialog: ConfirmationDialog::new("Unload", "Confirm unload"), + target_id: tab + .selected_identity + .as_ref() + .expect("selected identity") + .identity + .id(), + }); + + let changed = tab.reconcile_selected_identity(&Some(qualified_identity_with(8, None))); + + assert!(changed, "the selected identity changed"); + assert!( + tab.confirm_unload.is_none(), + "the old identity's unload confirmation must be dismissed" + ); + } + + #[test] + fn confirmed_unload_action_uses_the_captured_target() { + let captured_target = Identifier::from([0x41; 32]); + let newly_selected = Identifier::from([0x42; 32]); + + let action = confirmed_unload_action(captured_target); + + assert!(matches!( + action, + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::UnloadIdentity { + identity_id, + })) if identity_id == captured_target && identity_id != newly_selected + )); + } + + #[test] + fn unload_dialog_label_prefers_alias_and_falls_back_to_base58_id() { + let aliased = qualified_identity_with(9, Some(" Daily identity ")); + let unaliased = qualified_identity_with(10, None); + + assert_eq!(identity_unload_label(&aliased), "Daily identity"); + assert_eq!( + identity_unload_label(&unaliased), + unaliased.identity.id().to_string(Encoding::Base58) + ); + } + #[test] fn has_changes_tracks_baseline() { let mut tab = SettingsTab::new(); diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs index 1195125e1..524074c92 100644 --- a/src/wallet_backend/dashpay.rs +++ b/src/wallet_backend/dashpay.rs @@ -1140,6 +1140,28 @@ impl WalletBackend { .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) } + /// Delete every Global reverse-address mapping owned by `identity_id`. + pub fn dashpay_clear_identity_addr_map( + &self, + identity_id: &Identifier, + ) -> Result<(), TaskError> { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + + let owner_prefix = format!( + "{KV_PREFIX_ADDR_MAP}{}:", + identity_id.to_string(Encoding::Base58) + ); + let kv = self.kv(); + let keys = kv + .list(DetScope::Global, Some(&owner_prefix)) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })?; + for key in keys { + kv.delete(DetScope::Global, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })?; + } + Ok(()) + } + /// Write DET-local `(created_at_ms, confirmed_at_ms)` timestamps for a /// payment in the k/v sidecar, keyed by transaction id. Upstream /// `PaymentEntry` carries no timestamps of its own, so this is the @@ -1288,11 +1310,8 @@ impl WalletBackend { /// per-contact private memos, address-index cursors, the blocked / declined / /// withdrawn markers, and paid-action recovery journals. /// - /// The remaining Global-scoped overlays (timestamps, reverse address map) - /// are not owner-scoped and are swept by the `det:dashpay:` Global prefix in - /// [`crate::context::AppContext::clear_network_database`]; this method - /// covers the overlays that live under [`DetScope::Identity`] of the owner, - /// which that Global sweep can no longer reach. + /// Global-scoped overlays are cleared separately because this method covers + /// only the overlays under [`DetScope::Identity`] of the owner. pub fn dashpay_clear_owner_overlays(&self, owner: &Identifier) -> Result<(), TaskError> { let owner_buf = owner.to_buffer(); let scope = DetScope::Identity(&owner_buf); From 0c7bb214e4f4366df0edae084996760bc9491da9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:38:30 +0000 Subject: [PATCH 06/46] fix: satisfy clippy doc_lazy_continuation in identity settings module doc Merging the three issue #889 branches surfaced a lint that -D warnings now enforces on the module doc comment's bullet list: a trailing paragraph directly abutting the last item reads as an unindented list continuation. Add the missing blank line. Co-Authored-By: Claude Sonnet 5 --- src/ui/identity/settings.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 7fe49a21e..8e340aa3d 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -14,6 +14,7 @@ //! - **Delete social profile** — no `DashPayTask::DeleteProfile` variant. //! - **Add / remove alias** and **Make primary** — no `IdentityTask::AddAlias` //! / `RemoveAlias` / `MakePrimaryAlias` variants. +//! //! These appear as `Gated(missing_task)` non-interactive rows with the copy //! from design-spec §D (tooltip catalog entries #49 and #59). A TODO comment //! marks each one so the backend follow-up can search for the flag. From 18a64459a657ae7f8b85c4cba8ff5df3d9d2f89d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:45:11 +0000 Subject: [PATCH 07/46] docs: add changelog entries for issue #889 fixes Cover the masternode-load type-confusion fix and the new identity unload/removal capability shipped alongside it. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de229b17..a76fa215d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Unload an identity from this device**: Identity Hub → Settings now has a + working "Unload this identity from this device" action. It removes every + piece of locally stored data for that identity (keys, cached profile, + DashPay data) while leaving your other identities untouched. The identity + itself is unaffected on the network and can be loaded again at any time. + - **Automatic Platform node refresh during upgrades**: migrating a pre-1.0 installation now triggers a best-effort Mainnet or Testnet node refresh. Failed attempts retry on later launches until fresh addresses are saved and @@ -50,6 +56,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). does not support shielded sending from when the current interface mode does not unlock it. +- **Loading a masternode or evonode identity that was already loaded as a + regular identity no longer gets stuck**: the load screen now recognizes a + matching identity that hasn't finished loading and completes it correctly + instead of endlessly reporting it as already present. + ### Changed - **Shielded transactions are available on supported networks**: sending, From ea2d0b620cd5b7c6e8bf0d29dcb04455226237e8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:18:12 +0000 Subject: [PATCH 08/46] fix: close deletion-ordering and lock-poisoning gaps in identity unload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial re-review of e984641b/16fbc1b4 found two real gaps still open at HEAD: - delete_local_qualified_identity cleared vault keys (irreversible) before removing the identity from the index. A failure in either of the two steps that followed left a "zombie" identity: still visible in the UI, but with its private keys already gone. Reorder so the index entry drops first — clear_identity_vault_keys must still run before purge_identity_scope, since it reads the identity blob that purge deletes. Add a regression test that corrupts a real identity's stored blob to force a natural clear_identity_vault_keys failure and asserts the identity is already hidden from the index despite it. - unload_identity() propagated wallet-lock poisoning via `?` on self.wallets, inconsistent with this exact lock's documented self-healing convention (wallet_backend/poison.rs) and its sibling call sites in wallet_lifecycle. Route through read_recover/ write_recover so an unrelated poisoned lock can't fail an unload after the destructive work already succeeded. Also derives the test network from the context instead of a hardcoded Testnet literal in three places, so seed/assert/delete provably agree on the same value production code uses. Co-Authored-By: Claude Sonnet 5 --- src/backend_task/identity/unload_identity.rs | 5 +- src/context/identity_db.rs | 74 +++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/backend_task/identity/unload_identity.rs b/src/backend_task/identity/unload_identity.rs index 58c252d42..f676a1a4f 100644 --- a/src/backend_task/identity/unload_identity.rs +++ b/src/backend_task/identity/unload_identity.rs @@ -7,6 +7,7 @@ use dash_sdk::platform::Identifier; use super::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::wallet_backend::poison::RwLockRecover; fn retain_other_identities(identities: &mut HashMap, identity_id: &Identifier) { identities.retain(|_, identity| identity.id() != *identity_id); @@ -19,9 +20,9 @@ impl AppContext { ) -> Result { self.delete_local_qualified_identity(&identity_id)?; - let wallets = self.wallets.read()?; + let wallets = self.wallets.read_recover(); for wallet in wallets.values() { - retain_other_identities(&mut wallet.write()?.identities, &identity_id); + retain_other_identities(&mut wallet.write_recover().identities, &identity_id); } drop(wallets); diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index cd624d64b..d959c2bab 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -978,9 +978,15 @@ impl AppContext { } Err(error) => return Err(error), } + // Drop the identity from the index BEFORE the irreversible vault-key + // clear: a fault in either of the next two steps must never leave a + // "zombie" identity that is still visible but already missing its + // keys. `clear_identity_vault_keys` must still run before + // `purge_identity_scope`, since it reads the identity blob that + // `purge_identity_scope` deletes. + index_remove_identity(&kv, &id)?; self.clear_identity_vault_keys(&kv, &id)?; purge_identity_scope(&kv, &id)?; - index_remove_identity(&kv, &id)?; load_guard.loaded(); Ok(()) } @@ -1906,7 +1912,7 @@ mod tests { backend .identity_meta() .set( - Network::Testnet, + ctx.network(), &target_buf, &IdentityMeta { password_hint: Some("target hint".into()), @@ -2015,7 +2021,7 @@ mod tests { assert!( backend .identity_meta() - .get(Network::Testnet, &target_buf) + .get(ctx.network(), &target_buf) .is_none(), "target identity metadata must be removed" ); @@ -2071,6 +2077,68 @@ mod tests { backend.shutdown().await; } + /// A failure partway through deletion must never leave a "zombie" identity: + /// still indexed (so still visible in the UI) with its vault keys already + /// gone. `index_remove_identity` must run before the irreversible vault-key + /// clear, so a fault anywhere from that point on still leaves the identity + /// hidden rather than visibly broken. + /// + /// The fault is a corrupted stored blob (bad `qi_bytes`) written directly + /// after a real insert — a real, naturally-occurring failure of + /// `clear_identity_vault_keys`'s `decode_stored_identity` call, not a + /// simulated one — driving the actual `delete_local_qualified_identity` + /// entry point end to end. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn code_001_a_deletion_fault_after_index_removal_leaves_no_visible_zombie() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let target_id = Identifier::from([0x91; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0x92; 32], [0x93; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + assert!( + ctx.local_identity_ids() + .expect("read index") + .contains(&target_id), + "the identity must be indexed before deletion" + ); + + // Corrupt the stored blob in place: the index and wallet association + // are untouched, but `clear_identity_vault_keys`'s decode will fail. + let id_buf = target_id.to_buffer(); + let kv = ctx.det_kv().expect("det kv"); + kv.put(DetScope::Identity(&id_buf), IDENTITY_KEY, &stored("User")) + .expect("corrupt the stored blob"); + + let result = ctx.delete_local_qualified_identity(&target_id); + assert!( + result.is_err(), + "the corrupted blob must surface as an error" + ); + + assert!( + !ctx.local_identity_ids() + .expect("read index") + .contains(&target_id), + "the identity must already be hidden from the index even though a \ + later cleanup step failed — never a visible entry with its keys \ + already gone" + ); + + backend.shutdown().await; + } + /// Load-path migration — `migrate_keystore_to_vault` content-detects Clear/AlwaysClear, /// stores them in the vault FIRST, then rewrites the blob to InVault. /// Asserts: vault-first (the raw bytes are present), the wallet-derived key From 38376703521adb4c9e597688bc3c453e30cd87c5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:03:43 +0000 Subject: [PATCH 09/46] fix(clippy): confine wallet guards in unload test to satisfy await_holding_lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `identity_unload_handler_clears_wallet_cache_and_identity_selection` test held `RwLockReadGuard`s (`wallets`, inner `wallet`) at function-body scope with explicit `drop()` calls before `backend.shutdown().await`. clippy 1.92's `await_holding_lock` keys on the guards' lexical binding scope and does not credit the bare `drop()`, so it still flagged both guards as live across the await — failing `-D warnings` in CI. Confine both reads to a nested block that yields owned values (`bool`, `Option`), so no guard type enters the async coroutine layout. Assertions and their messages are unchanged. Co-Authored-By: Claude Opus --- src/backend_task/identity/unload_identity.rs | 33 ++++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/backend_task/identity/unload_identity.rs b/src/backend_task/identity/unload_identity.rs index f676a1a4f..b432615ec 100644 --- a/src/backend_task/identity/unload_identity.rs +++ b/src/backend_task/identity/unload_identity.rs @@ -113,26 +113,33 @@ mod tests { result, BackendTaskSuccessResult::UnloadedIdentity(identity_id) if identity_id == target_id )); - let wallets = ctx.wallets().read().expect("read wallets"); - let wallet = wallets - .get(&wallet_seed_hash) - .expect("test wallet remains") - .read() - .expect("read test wallet"); + // Snapshot the cache state under the wallet guards, releasing them at the + // block's end so no guard is live across `backend.shutdown().await` below + // (clippy::await_holding_lock). + let (target_evicted, cached_sibling) = { + let wallets = ctx.wallets().read().expect("read wallets"); + let wallet = wallets + .get(&wallet_seed_hash) + .expect("test wallet remains") + .read() + .expect("read test wallet"); + ( + wallet + .identities + .values() + .all(|identity| identity.id() != target_id), + wallet.identities.get(&7).map(IdentityGettersV0::id), + ) + }; assert!( - wallet - .identities - .values() - .all(|identity| identity.id() != target_id), + target_evicted, "the target identity must be evicted from the wallet cache" ); assert_eq!( - wallet.identities.get(&7).map(IdentityGettersV0::id), + cached_sibling, Some(sibling_id), "the sibling identity must remain cached" ); - drop(wallet); - drop(wallets); assert_eq!( ctx.selected_identity_id(), None, From b7d57722a921848a5ff6c87f76209a3b6d458627 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:09:27 +0000 Subject: [PATCH 10/46] docs(unload): align tooltip and changelog with the irreversible-key-deletion warning The unload confirmation dialog was correctly rewritten in this PR to warn that unloading deletes the identity's local private keys and that reloading needs its recovery information. Two stale, optimistic strings on the same screen still promised effortless reloading and contradicted it: - `TIP_UNLOAD` said "you can load it again later" with no caveat. - CHANGELOG's Unload entry said it "can be loaded again at any time". For identities whose keys were entered manually (masternode/evonode owner, voting, payout, or an imported single key), those keys live nowhere else once deleted, so "at any time" is a promise the code can't keep. Reword both to match the dialog: reloading requires recovery information because the local private keys are deleted. Co-Authored-By: Claude Opus --- CHANGELOG.md | 3 ++- src/ui/identity/settings.rs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 381231d81..c3eae8d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). working "Unload this identity from this device" action. It removes every piece of locally stored data for that identity (keys, cached profile, DashPay data) while leaving your other identities untouched. The identity - itself is unaffected on the network and can be loaded again at any time. + itself is unaffected on the network, but because unloading deletes its local + private keys you will need its recovery information to load it again. - **Automatic Platform node refresh during upgrades**: migrating a pre-1.0 installation now triggers a best-effort Mainnet or Testnet node refresh. diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 8e340aa3d..fdcf2848d 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -58,8 +58,8 @@ const TIP_ADD_KEY: &str = const TIP_MANAGE_KEYS: &str = "View this identity's keys and their security settings."; const TIP_VIEW_USERNAMES: &str = "Open the complete list of your registered usernames."; const TIP_REFRESH: &str = "Fetch the latest state of this identity from the network."; -const TIP_UNLOAD: &str = "Remove this identity from this device. It remains on Dash Platform — you can load it \ - again later."; +const TIP_UNLOAD: &str = "Remove this identity from this device, deleting its private keys and local data. It \ + remains on Dash Platform, but you will need its recovery information to load it again."; const TIP_SAVE_ALIAS: &str = "Save this name on this device."; const TIP_ID_COPY: &str = "Copy the full identity ID to your clipboard."; From 45c011f39346fef5c5561c8a81c836c6dbbcd1a0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:29:23 +0000 Subject: [PATCH 11/46] fix(identity): keep the vault-key wipe running when local cleanup fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete_local_qualified_identity fail-fast (`?`) through four DashPay/ metadata cleanup steps before reaching the destructive sequence (index_remove_identity -> clear_identity_vault_keys -> purge_identity_scope). This function backs clear_network_database's "delete all local data" (F60) full-wipe sweep, which treats it as best-effort per identity — so a k/v-only fault in one identity's DashPay cleanup (e.g. a corrupted overlay entry) silently skipped wiping that identity's Tier-1 private keys, even though the sweep's entire point is guaranteeing every identity's secret-bearing state is erased. Before this PR's refactor the cleanup and the vault wipe were independent best-effort steps; the refactor accidentally coupled them. Attempt all four cleanup steps regardless of earlier failures, keeping only the first error. Always proceed to the unchanged, unreordered destructive sequence; a destructive-step failure still takes precedence over a recorded cleanup failure. Add a regression test that fails only the owner-overlay delete via a second-connection SQLite trigger and asserts cleanup continues past it while the vault key is still wiped. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Codex Sol --- src/backend_task/error.rs | 6 +- src/context/identity_db.rs | 188 +++++++++++++++++++++++++++++++++---- 2 files changed, 172 insertions(+), 22 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index f4dc91137..e047eafbb 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -670,9 +670,9 @@ pub enum TaskError { source: std::sync::Arc, }, - /// Owner-attributable local state could not be fully removed while - /// unloading an identity. The identity id identifies the affected local - /// record; the nested typed error preserves the storage failure for logs. + /// Owner-attributable local state could not be fully removed while unloading + /// an identity. Cleanup continues after failures, but only the first failure + /// is preserved in the nested typed error for logs. #[error( "Some local data for identity {identity_id} could not be fully removed. Try unloading it again." )] diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index d959c2bab..642a09f74 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -946,29 +946,27 @@ impl AppContext { source: Arc::new(source), }, )?; + let mut cleanup_error = None; match self.wallet_backend() { Ok(backend) => { - backend - .dashpay_clear_owner_overlays(identifier) - .map_err(|source| TaskError::IdentityUnloadCleanupFailed { - identity_id: *identifier, - source: Box::new(source), - })?; + let mut record_cleanup_result = |result: std::result::Result<(), TaskError>| { + if cleanup_error.is_none() { + cleanup_error = + result + .err() + .map(|source| TaskError::IdentityUnloadCleanupFailed { + identity_id: *identifier, + source: Box::new(source), + }); + } + }; + + record_cleanup_result(backend.dashpay_clear_owner_overlays(identifier)); // Conversation/payment timestamps that are not keyed by this identity // may be shared; full-wallet teardown is the safe reclamation boundary. - backend - .dashpay_clear_identity_timestamps(identifier) - .map_err(|source| TaskError::IdentityUnloadCleanupFailed { - identity_id: *identifier, - source: Box::new(source), - })?; - backend - .dashpay_clear_identity_addr_map(identifier) - .map_err(|source| TaskError::IdentityUnloadCleanupFailed { - identity_id: *identifier, - source: Box::new(source), - })?; - backend.identity_meta().delete(self.network, &id)?; + record_cleanup_result(backend.dashpay_clear_identity_timestamps(identifier)); + record_cleanup_result(backend.dashpay_clear_identity_addr_map(identifier)); + record_cleanup_result(backend.identity_meta().delete(self.network, &id)); } Err(TaskError::WalletBackendNotYetWired) => { tracing::warn!( @@ -987,6 +985,9 @@ impl AppContext { index_remove_identity(&kv, &id)?; self.clear_identity_vault_keys(&kv, &id)?; purge_identity_scope(&kv, &id)?; + if let Some(error) = cleanup_error { + return Err(error); + } load_guard.loaded(); Ok(()) } @@ -2037,6 +2038,155 @@ mod tests { backend.shutdown().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn identity_unload_continues_cleanup_and_wipes_vault_after_overlay_failure() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::model::dashpay::ContactPrivateInfo; + use crate::model::qualified_identity::identity_meta::IdentityMeta; + use crate::utils::egui_mpsc::SenderAsync; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let target_id = Identifier::from([0xA1; 32]); + let contact_id = Identifier::from([0xA2; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0xA3; 32], [0xA4; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + + let target_buf = target_id.to_buffer(); + let target_vault = IdentityKeyView::new(backend.secret_store(), target_buf); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read target key") + .is_some(), + "target key must exist before removal" + ); + + backend + .dashpay_set_private_info( + &target_id, + &contact_id, + &ContactPrivateInfo { + nickname: "target contact".into(), + notes: "target note".into(), + is_hidden: false, + }, + ) + .expect("seed target owner overlay"); + backend + .dashpay_set_timestamps(&target_id, 11, 12) + .expect("seed target timestamps"); + backend + .dashpay_set_address_mapping(&target_id, "target-address", &contact_id, 1) + .expect("seed target address mapping"); + backend + .identity_meta() + .set( + ctx.network(), + &target_buf, + &IdentityMeta { + password_hint: Some("target hint".into()), + }, + ) + .expect("seed target identity metadata"); + + let kv = backend.kv(); + let overlay_keys = kv + .list( + DetScope::Identity(&target_buf), + Some("det:dashpay:private:"), + ) + .expect("list target owner overlays"); + let [overlay_key] = overlay_keys.as_slice() else { + panic!("exactly one target owner overlay must be seeded"); + }; + let timestamps_key = format!( + "det:dashpay:timestamps:{}", + target_id.to_string(Encoding::Base58) + ); + + let persister_path = backend.spv_storage_dir().join("platform-wallet.sqlite"); + let fault_connection = + rusqlite::Connection::open(&persister_path).expect("open persister second handle"); + let trigger_name = "fail_target_owner_overlay_delete"; + let trigger_sql = format!( + "CREATE TRIGGER {trigger_name} + BEFORE DELETE ON meta_identity + WHEN OLD.identity_id = X'{}' AND OLD.key = '{}' + BEGIN + SELECT RAISE(FAIL, 'injected owner overlay delete failure'); + END;", + hex::encode(target_buf), + overlay_key.replace('\'', "''"), + ); + fault_connection + .execute_batch(&trigger_sql) + .expect("install owner-overlay delete trigger"); + + match ctx.delete_local_qualified_identity(&target_id) { + Err(TaskError::IdentityUnloadCleanupFailed { + identity_id, + source, + }) => { + assert_eq!(identity_id, target_id); + assert!( + matches!(*source, TaskError::DashpaySidecarStorage { .. }), + "the first cleanup failure must preserve its DashPay source" + ); + } + other => panic!("expected identity-unload cleanup failure, got {other:?}"), + } + + assert!( + kv.get::(DetScope::Identity(&target_buf), overlay_key) + .expect("read retained owner overlay") + .is_some(), + "the targeted owner overlay must survive its injected delete failure" + ); + assert!( + kv.get::<(i64, i64)>(DetScope::Global, ×tamps_key) + .expect("read target timestamps") + .is_none(), + "timestamp cleanup must continue after the owner-overlay failure" + ); + assert!( + backend + .dashpay_get_address_mapping(&target_id, "target-address") + .expect("read target address mapping") + .is_none(), + "address-map cleanup must continue after the owner-overlay failure" + ); + assert!( + backend + .identity_meta() + .get(ctx.network(), &target_buf) + .is_none(), + "identity-metadata cleanup must continue after the owner-overlay failure" + ); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read removed target key") + .is_none(), + "target vault keys must be removed despite the cleanup failure" + ); + + fault_connection + .execute_batch(&format!("DROP TRIGGER {trigger_name};")) + .expect("remove owner-overlay delete trigger"); + backend.shutdown().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn identity_unload_respects_an_in_flight_load_claim() { use crate::app::TaskResult; From 6d05025007376d14c99f12ede0fcc340a8a2bbe5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:45:17 +0000 Subject: [PATCH 12/46] fix(identity): tailor the unload recovery warning to actual key derivation The "Unload this identity" tooltip, confirmation dialog, and CHANGELOG entry unconditionally warned that reloading needs the identity's "recovery information" after unload. That's only true for keys with no other copy on this device (manually-entered masternode/evonode owner/voting/payout keys, or an imported single key) -- an HD-wallet- derived key re-derives automatically from the still-loaded wallet seed on next load, no recovery information needed. Add QualifiedIdentity::requires_recovery_information_after_unload(), backed by KeyStorage::has_keys_without_available_wallet(): checks each stored key's wallet-derivation metadata against the identity's actually *loaded* wallets (associated_wallets alone isn't a valid per-key classifier -- it lists every loaded wallet, not which key derives from which). An identity with any key lacking an available wallet -- local- only, mixed, or wallet-derived but the wallet isn't currently loaded -- still gets the stronger warning; only all-wallet-derived-and-available identities get the lighter one. Two complete sentences per branch, no glued conditional clause, per the repo's i18n-ready-strings convention. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Codex Sol --- CHANGELOG.md | 5 +- .../encrypted_key_storage.rs | 91 +++++++++++++++ src/model/qualified_identity/mod.rs | 8 ++ src/ui/identity/settings.rs | 105 ++++++++++++++++-- 4 files changed, 197 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3eae8d8b..cddc4e0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). working "Unload this identity from this device" action. It removes every piece of locally stored data for that identity (keys, cached profile, DashPay data) while leaving your other identities untouched. The identity - itself is unaffected on the network, but because unloading deletes its local - private keys you will need its recovery information to load it again. + itself is unaffected on the network. Wallet-derived private keys can be + restored from the wallet. To load the identity again, you need recovery + information for any keys stored only on this device. - **Automatic Platform node refresh during upgrades**: migrating a pre-1.0 installation now triggers a best-effort Mainnet or Testnet node refresh. diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index 25fe42699..9d61c697b 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -425,6 +425,23 @@ impl KeyStorage { .collect() } + /// Whether any stored private key lacks an available HD wallet. + pub(crate) fn has_keys_without_available_wallet( + &self, + wallet_is_available: impl Fn(&WalletSeedHash) -> bool, + ) -> bool { + self.private_keys.values().any(|(public_key, private_key)| { + let wallet_seed_hash = match private_key { + PrivateKeyData::AtWalletDerivationPath(path) => Some(&path.wallet_seed_hash), + _ => public_key + .in_wallet_at_derivation_path + .as_ref() + .map(|path| &path.wallet_seed_hash), + }; + wallet_seed_hash.is_none_or(|seed_hash| !wallet_is_available(seed_hash)) + }) + } + /// Inserts an unencrypted key into `ClearKeyStorage`. Returns an error if the storage is closed. pub fn insert_non_encrypted( &mut self, @@ -663,6 +680,80 @@ mod tests { ks } + #[test] + fn wallet_derived_keys_do_not_need_separate_recovery_information() { + let storage = { + let pv = PlatformVersion::latest(); + let key = IdentityPublicKey::random_key(4, Some(4), pv); + KeyStorage::from(BTreeMap::from([( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + WalletDerivationPath { + wallet_seed_hash: [0x04; 32], + derivation_path: DerivationPath::from(vec![]), + }, + ), + )])) + }; + + assert!( + !storage.has_keys_without_available_wallet(|seed_hash| { *seed_hash == [0x04; 32] }) + ); + } + + #[test] + fn wallet_derived_key_needs_recovery_when_its_wallet_is_unavailable() { + let pv = PlatformVersion::latest(); + let key = IdentityPublicKey::random_key(6, Some(6), pv); + let storage = KeyStorage::from(BTreeMap::from([( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + WalletDerivationPath { + wallet_seed_hash: [0x06; 32], + derivation_path: DerivationPath::from(vec![]), + }, + ), + )])); + + assert!(storage.has_keys_without_available_wallet(|_| false)); + } + + #[test] + fn mixed_wallet_and_local_keys_need_separate_recovery_information() { + let local_high = distinctive_secret(); + let mut local_medium = local_high; + local_medium[0] ^= 0xFF; + let storage = storage_with_plaintext_and_derived(local_high, local_medium); + + assert!(storage.has_keys_without_available_wallet(|_| true)); + } + + #[test] + fn vault_key_with_wallet_metadata_does_not_need_separate_recovery_information() { + let pv = PlatformVersion::latest(); + let key = IdentityPublicKey::random_key(5, Some(5), pv); + let wallet_path = WalletDerivationPath { + wallet_seed_hash: [0x05; 32], + derivation_path: DerivationPath::from(vec![]), + }; + let storage = KeyStorage::from(BTreeMap::from([( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from_identity_public_key_in_wallet( + key, + Some(wallet_path), + ), + PrivateKeyData::InVault, + ), + )])); + + assert!( + !storage.has_keys_without_available_wallet(|seed_hash| { *seed_hash == [0x05; 32] }) + ); + } + /// TS-RESID-02 — a bincode blob written BEFORE `InVault` was appended /// (discriminants 0–3 only) still decodes into the extended enum, and the /// new highest-index variant round-trips. Guards the bincode-discriminant diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 123db5497..1073a3507 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -600,6 +600,14 @@ impl QualifiedIdentity { presence } + /// Whether unloading would remove a key that no loaded HD wallet can restore. + pub fn requires_recovery_information_after_unload(&self) -> bool { + self.private_keys + .has_keys_without_available_wallet(|seed_hash| { + self.associated_wallets.contains_key(seed_hash) + }) + } + /// Resolve the 32-byte private key for `(target, key_id)` without ever /// reading a wallet's parked seed. /// diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index fdcf2848d..075cfa430 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -58,8 +58,10 @@ const TIP_ADD_KEY: &str = const TIP_MANAGE_KEYS: &str = "View this identity's keys and their security settings."; const TIP_VIEW_USERNAMES: &str = "Open the complete list of your registered usernames."; const TIP_REFRESH: &str = "Fetch the latest state of this identity from the network."; -const TIP_UNLOAD: &str = "Remove this identity from this device, deleting its private keys and local data. It \ - remains on Dash Platform, but you will need its recovery information to load it again."; +const TIP_UNLOAD_WALLET_DERIVED: &str = "Remove this identity and its local data from this device. It remains on \ + Dash Platform, and its wallet-derived private keys can be restored when you load it again."; +const TIP_UNLOAD_RECOVERY_REQUIRED: &str = "Remove this identity from this device, deleting its private keys and \ + local data. It remains on Dash Platform, but you will need its recovery information to load it again."; const TIP_SAVE_ALIAS: &str = "Save this name on this device."; const TIP_ID_COPY: &str = "Copy the full identity ID to your clipboard."; @@ -716,19 +718,13 @@ impl SettingsTab { ui.add_space(6.0); let unload = ComponentStyles::add_danger_button(ui, "Unload this identity from this device") - .clickable_tooltip(TIP_UNLOAD); + .clickable_tooltip(identity_unload_tip(identity)); if unload.clicked() { let target_id = identity.identity.id(); - let identity_label = identity_unload_label(identity); self.confirm_unload = Some(PendingIdentityUnload { dialog: ConfirmationDialog::new( "Unload this identity", - format!( - "Identity \"{identity_label}\" will be permanently unloaded from \ - this device, deleting its private keys and local data. It remains \ - on Dash Platform, but you will need its recovery information to \ - load it again." - ), + identity_unload_confirmation_message(identity), ) .confirm_text(Some("Permanently unload")) .cancel_text(Some("Keep identity")) @@ -946,6 +942,45 @@ fn identity_unload_label(identity: &QualifiedIdentity) -> String { .unwrap_or_else(|| identity.identity.id().to_string(Encoding::Base58)) } +fn identity_unload_tip(identity: &QualifiedIdentity) -> &'static str { + identity_unload_tip_for(identity.requires_recovery_information_after_unload()) +} + +fn identity_unload_tip_for(recovery_information_required: bool) -> &'static str { + if recovery_information_required { + TIP_UNLOAD_RECOVERY_REQUIRED + } else { + TIP_UNLOAD_WALLET_DERIVED + } +} + +fn identity_unload_confirmation_message(identity: &QualifiedIdentity) -> String { + let identity_label = identity_unload_label(identity); + identity_unload_confirmation_message_for( + &identity_label, + identity.requires_recovery_information_after_unload(), + ) +} + +fn identity_unload_confirmation_message_for( + identity_label: &str, + recovery_information_required: bool, +) -> String { + if recovery_information_required { + format!( + "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + deleting its private keys and local data. It remains on Dash Platform, but you will \ + need its recovery information to load it again." + ) + } else { + format!( + "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + deleting its local data. It remains on Dash Platform, and its wallet-derived private \ + keys can be restored when you load it again." + ) + } +} + fn confirmed_unload_action(target_id: Identifier) -> AppAction { AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::UnloadIdentity { identity_id: target_id, @@ -1031,9 +1066,16 @@ fn identity_type_badge(kind: IdentityType) -> (&'static str, &'static str) { #[cfg(test)] mod tests { use super::*; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::model::qualified_identity::encrypted_key_storage::{ + PrivateKeyData, WalletDerivationPath, + }; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use crate::model::qualified_identity::{IdentityStatus, IdentityType}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use std::collections::BTreeMap; @@ -1165,6 +1207,49 @@ mod tests { ); } + #[test] + fn unload_dialog_omits_recovery_warning_for_wallet_derived_keys() { + assert_eq!( + identity_unload_confirmation_message_for("Wallet identity", false), + "Identity \"Wallet identity\" will be permanently unloaded from this device, \ + deleting its local data. It remains on Dash Platform, and its wallet-derived \ + private keys can be restored when you load it again." + ); + assert_eq!(identity_unload_tip_for(false), TIP_UNLOAD_WALLET_DERIVED); + } + + #[test] + fn unload_dialog_warns_about_recovery_information_for_mixed_keys() { + let mut identity = qualified_identity_with(12, Some("Mixed identity")); + let derived_key = IdentityPublicKey::random_key(1, Some(1), PlatformVersion::latest()); + identity.private_keys.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, derived_key.id()), + ( + QualifiedIdentityPublicKey::from(derived_key), + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x12; 32], + derivation_path: DerivationPath::from(vec![]), + }), + ), + ); + let local_key = IdentityPublicKey::random_key(2, Some(2), PlatformVersion::latest()); + identity.private_keys.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, local_key.id()), + ( + QualifiedIdentityPublicKey::from(local_key), + PrivateKeyData::InVault, + ), + ); + + assert_eq!( + identity_unload_confirmation_message(&identity), + "Identity \"Mixed identity\" will be permanently unloaded from this device, deleting \ + its private keys and local data. It remains on Dash Platform, but you will need its \ + recovery information to load it again." + ); + assert_eq!(identity_unload_tip(&identity), TIP_UNLOAD_RECOVERY_REQUIRED); + } + #[test] fn has_changes_tracks_baseline() { let mut tab = SettingsTab::new(); From b6bd214497edef54b47e0409c36625b86773e89a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:21:31 +0000 Subject: [PATCH 13/46] fix: address PR #925 review follow-ups (masternode identity lifecycle) Resolves the two blocking findings from thepastaclaw's review plus the non-blocking correctness/doc gaps from coderabbitai and the internal review pass on PR #925 (issue #889): - validate_loaded_identity_type now rejects both directions: a regular identity loaded via the Masternode/Evonode entry point is caught too (new TaskError::IdentityIsNotMasternode), not just the reverse. - Add a durable, per-network "forgotten identity" marker (new forgotten_identities table, v39 migration) so an unloaded wallet-derived identity is no longer silently resurrected by the next discovery/unlock scan; only an explicit user-driven load clears it. Discovery call sites are now tagged with a typed mode (Background/WalletUnlock/ExplicitSearch) so only explicit reloads bypass the marker. - unload_identity and its UI callers (identities_screen, detail_screen) now reconcile in-memory wallet-cache/selection state even when storage deletion committed but cleanup-only failed, instead of aborting via a bare `?` and leaving memory inconsistent with storage. clear_network_database keeps surfacing the underlying vault-key error type unwrapped, preserving its existing structural-matching contract. - ProfileCache invalidates a unloaded identity's cached/in-flight DashPay profile state (generation-guarded, so a late in-flight response can't repopulate stale data) while still self-healing after an errored profile load via reset(). - docs/user-stories.md (IDN-017) now matches the existing conditional UI copy: the permanent-key-loss warning only applies to non-wallet- derived identities. - Drop an ephemeral review-ID prefix from a committed test name; switch a Mutex lock to the project's established poison-recovery pattern. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Codex Sol --- CHANGELOG.md | 6 + docs/user-stories.md | 5 +- src/backend_task/error.rs | 24 +- .../identity/discover_identities.rs | 222 ++++++++++++++++-- src/backend_task/identity/load_identity.rs | 40 +++- .../identity/load_identity_by_dpns_name.rs | 4 + .../identity/load_identity_from_wallet.rs | 7 +- src/backend_task/identity/mod.rs | 2 + src/backend_task/identity/unload_identity.rs | 157 ++++++++++++- src/context/identity_db.rs | 122 ++++++++-- src/context/wallet_lifecycle/bootstrap.rs | 10 +- src/context/wallet_lifecycle/spv.rs | 6 +- src/database/forgotten_identities.rs | 96 ++++++++ src/database/initialization.rs | 56 ++++- src/database/mod.rs | 1 + src/ui/identities/identities_screen.rs | 59 +++-- src/ui/identity/hub_screen.rs | 3 +- src/ui/identity/profile_cache.rs | 143 ++++++++++- src/ui/masternodes/detail_screen.rs | 58 +++-- 19 files changed, 910 insertions(+), 111 deletions(-) create mode 100644 src/database/forgotten_identities.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cddc4e0b6..62eaec0b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Identity unload and reload follow-ups for #889 / PR #925**: node load forms + now reject regular identities, wallet discovery keeps deliberately unloaded + identities unloaded until the user explicitly loads them again, partial + cleanup failures still reconcile the app's active identity state, and late + DashPay profile responses can no longer restore stale profile data. + - **Shielded availability notice**: now distinguishes when the connected network does not support shielded sending from when the current interface mode does not unlock it. diff --git a/docs/user-stories.md b/docs/user-stories.md index 49212bd85..2a1b33885 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -650,7 +650,10 @@ As a user, I want the identities I loaded before an upgrade — and the keys the As a user, I want to unload one identity from this device so that I can recover from an incorrect import or stop keeping its private keys locally without removing a shared wallet. -- The Identity Hub asks for confirmation and warns that local private keys are permanently deleted before unloading. +- The Identity Hub asks for confirmation before unloading. If the identity is + wallet-derived, it explains that the identity can be loaded again from the + wallet's recovery seed. Otherwise, it warns that keys stored only on this + device are permanently deleted and require separate recovery information. - Unloading removes only the selected identity's local keys, metadata, DashPay overlays, and device record while leaving the Platform identity unchanged. - Other identities on the same wallet and the wallet's recovery seed remain available. diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index e047eafbb..42aeacd14 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -674,7 +674,7 @@ pub enum TaskError { /// an identity. Cleanup continues after failures, but only the first failure /// is preserved in the nested typed error for logs. #[error( - "Some local data for identity {identity_id} could not be fully removed. Try unloading it again." + "Some local data for identity {identity_id} could not be fully removed. Load the identity again, then unload it to retry." )] IdentityUnloadCleanupFailed { identity_id: Identifier, @@ -682,6 +682,16 @@ pub enum TaskError { source: Box, }, + /// A user's choice to keep an unloaded identity off this device could not + /// be read or saved in the local database. + #[error( + "This identity could not be kept unloaded. Check available disk space and try again." + )] + ForgottenIdentityStorage { + #[source] + source: rusqlite::Error, + }, + /// An identity top-up history record could not be persisted to the /// per-network wallet k/v store. #[error("Could not save your top-up history. Check available disk space and try again.")] @@ -1474,6 +1484,13 @@ pub enum TaskError { )] IdentityIsMasternode { identity_id: Identifier }, + /// A masternode or evonode load fetched a regular identity. + /// Carries the resolved identity id for structured matching. + #[error( + "This identifier belongs to a regular identity, not a masternode or evonode. Load it from the Identities page instead." + )] + IdentityIsNotMasternode { identity_id: Identifier }, + /// The identity could not be constructed from the given parameters. #[error("Could not create the identity. Please check your input and try again.")] IdentityCreationError { @@ -2360,6 +2377,11 @@ pub enum TaskError { } impl TaskError { + /// Whether identity storage was already removed before this error occurred. + pub(crate) fn identity_was_removed(&self) -> bool { + matches!(self, Self::IdentityUnloadCleanupFailed { .. }) + } + /// Reclassifies SDK reachability failures when every configured DAPI address is exhausted. pub(crate) fn contextualize_dapi_availability( self, diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index bbfb96dc2..e1ac18e42 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -15,6 +15,27 @@ use std::sync::{Arc, RwLock}; /// concluding no identity is registered there. const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; +/// Whether discovery is automatic, follows unlock, or is user-requested. +#[derive(Clone, Copy)] +pub(crate) enum IdentityDiscoveryMode { + /// Automatic startup discovery; never prompts or restores unloaded identities. + Background, + /// Post-unlock discovery; may use the unlocked seed but never restores identities. + WalletUnlock, + /// User-started search; may deliberately restore an unloaded identity. + ExplicitSearch, +} + +impl IdentityDiscoveryMode { + fn allow_prompt(self) -> bool { + !matches!(self, Self::Background) + } + + fn explicitly_reloads_forgotten(self) -> bool { + matches!(self, Self::ExplicitSearch) + } +} + impl AppContext { /// Discover and load identities derived from a wallet by checking the /// network, with a rolling gap-limited lookahead. @@ -27,10 +48,10 @@ impl AppContext { /// prior-session high index is never missed even if the early indices are /// empty. /// - /// `allow_prompt` controls the secret path: with `true` (the interactive - /// search) a cold auth-key cache miss prompts for the passphrase; with - /// `false` (the background sweep) a locked, protected wallet is skipped - /// instead of prompting. + /// `mode` controls whether a cold secret-cache miss may prompt and whether + /// the scan is an explicit user request that may restore an identity the + /// user unloaded. Startup and wallet-unlock discovery always leave forgotten + /// identities untouched. /// /// When `progress` is `Some`, a [`BackendTaskSuccessResult::Progress`] event /// is sent before each probed index. @@ -38,7 +59,7 @@ impl AppContext { self: &Arc, wallet: &Arc>, seed_from_index: u32, - allow_prompt: bool, + mode: IdentityDiscoveryMode, progress: Option<&SenderAsync>, ) -> Result { use dash_sdk::platform::Fetch; @@ -64,7 +85,7 @@ impl AppContext { tracing::info!( seed = %hex::encode(seed_hash), seed_window = ?seed_window, - allow_prompt, + allow_prompt = mode.allow_prompt(), "Starting gap-limited identity discovery for wallet" ); @@ -101,7 +122,12 @@ impl AppContext { for key_index in 0..AUTH_KEY_LOOKUP_WINDOW { let public_key = match self - .resolve_identity_auth_pubkey(wallet, allow_prompt, current_index, key_index) + .resolve_identity_auth_pubkey( + wallet, + mode.allow_prompt(), + current_index, + key_index, + ) .await { Ok(key) => key, @@ -168,12 +194,13 @@ impl AppContext { identity, wallet, scan_network, - allow_prompt, + mode, current_index, ) .await { - Ok(()) => summary.stored = summary.stored.saturating_add(1), + Ok(true) => summary.stored = summary.stored.saturating_add(1), + Ok(false) => {} Err(e) => tracing::warn!( identity_id = %identity_id, error = %e, @@ -205,8 +232,13 @@ impl AppContext { wallet: &Arc>, max_identity_index: u32, ) -> Result<(), TaskError> { - self.discover_identities_gap_limited(wallet, max_identity_index, true, None) - .await?; + self.discover_identities_gap_limited( + wallet, + max_identity_index, + IdentityDiscoveryMode::WalletUnlock, + None, + ) + .await?; Ok(()) } @@ -229,27 +261,66 @@ impl AppContext { identity: dash_sdk::platform::Identity, wallet: &Arc>, scan_network: dash_sdk::dpp::dashcore::Network, - allow_prompt: bool, + mode: IdentityDiscoveryMode, identity_index: u32, - ) -> Result<(), TaskError> { + ) -> Result { if self.network != scan_network { tracing::debug!("Network changed mid-scan; skipping store of discovered identity"); - return Ok(()); + return Ok(false); } let identity_id = identity.id(); let seed_hash = wallet.read()?.seed_hash(); - let mut qualified_identity = self + let qualified_identity = self .build_qualified_identity_from_wallet( sdk, identity, wallet, - allow_prompt, + mode.allow_prompt(), identity_index, ) .await?; + if !self.persist_discovered_identity( + qualified_identity.clone(), + seed_hash, + identity_index, + mode.explicitly_reloads_forgotten(), + )? { + tracing::debug!( + identity_id = %identity_id, + "Skipped a discovered identity that the user unloaded" + ); + return Ok(false); + } + + if let Ok(mut wallet_guard) = wallet.write() { + wallet_guard + .identities + .insert(identity_index, qualified_identity.identity.clone()); + } + tracing::info!( + identity_id = %identity_id, + "Successfully loaded discovered identity" + ); + Ok(true) + } + + /// Persist one discovery result unless automatic discovery must leave it unloaded. + pub(crate) fn persist_discovered_identity( + &self, + mut qualified_identity: crate::model::qualified_identity::QualifiedIdentity, + seed_hash: crate::model::wallet::WalletSeedHash, + identity_index: u32, + explicit_reload: bool, + ) -> Result { + let identity_id = qualified_identity.identity.id(); + let load_guard = self.begin_identity_load(identity_id, None)?; + if self.is_identity_forgotten(&identity_id)? && !explicit_reload { + return Ok(false); + } + match self.get_identity_by_id(&identity_id)? { Some(existing) => { // Carry DET-only metadata onto the refreshed identity, then @@ -266,17 +337,11 @@ impl AppContext { )?; } } - - if let Ok(mut wallet_guard) = wallet.write() { - wallet_guard - .identities - .insert(identity_index, qualified_identity.identity.clone()); + if explicit_reload { + self.clear_forgotten_identity_after_explicit_load(&identity_id)?; } - tracing::info!( - identity_id = %identity_id, - "Successfully loaded discovered identity" - ); - Ok(()) + load_guard.loaded(); + Ok(true) } /// Build a QualifiedIdentity from a fetched Identity with wallet key derivation paths. @@ -437,3 +502,108 @@ impl AppContext { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + use crate::utils::egui_mpsc::SenderAsync; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, Identity}; + use std::collections::BTreeMap; + + fn wallet_derived_identity( + id: Identifier, + wallet: &Arc>, + identity_index: u32, + ) -> QualifiedIdentity { + let wallet_seed_hash = wallet.read().expect("read wallet").seed_hash(); + QualifiedIdentity { + identity: Identity::create_basic_identity(id, PlatformVersion::latest()) + .expect("create identity"), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: Vec::new(), + associated_wallets: BTreeMap::from([(wallet_seed_hash, Arc::clone(wallet))]), + secret_access: None, + wallet_index: Some(identity_index), + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn forgotten_identity_is_not_resurrected_by_discovery_and_explicit_load_clears_marker() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed([0x61; 64], Network::Testnet, None, None).expect("build wallet"), + )); + let wallet_seed_hash = wallet.read().expect("read wallet").seed_hash(); + ctx.wallets() + .write() + .expect("write wallets") + .insert(wallet_seed_hash, Arc::clone(&wallet)); + let identity_id = Identifier::from([0x62; 32]); + let identity = wallet_derived_identity(identity_id, &wallet, 4); + ctx.insert_local_qualified_identity(&identity, &Some((wallet_seed_hash, 4))) + .expect("insert wallet-derived identity"); + + ctx.unload_identity(identity_id) + .expect("unload wallet-derived identity"); + assert!( + ctx.is_identity_forgotten(&identity_id) + .expect("read forgotten marker") + ); + + let stored = ctx + .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, false) + .expect("simulate discovery persistence"); + assert!(!stored, "discovery must skip a forgotten identity"); + assert!( + ctx.get_identity_by_id(&identity_id) + .expect("read identity") + .is_none(), + "discovery must not resurrect an unloaded identity" + ); + + assert!( + ctx.persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, true) + .expect("simulate explicit wallet load"), + "an explicit load must restore the identity" + ); + assert!( + !ctx.is_identity_forgotten(&identity_id) + .expect("read cleared marker") + ); + + ctx.delete_local_qualified_identity(&identity_id) + .expect("remove identity without recording another unload"); + assert!( + ctx.persist_discovered_identity(identity, wallet_seed_hash, 4, false) + .expect("simulate discovery after explicit reload"), + "discovery must work normally after the explicit load clears the marker" + ); + assert!( + ctx.get_identity_by_id(&identity_id) + .expect("read rediscovered identity") + .is_some() + ); + + backend.shutdown().await; + } +} diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 45b832dc4..1556ade3c 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -77,7 +77,7 @@ fn merge_existing_keys_into(new: &mut QualifiedIdentity, existing: QualifiedIden } } -fn validate_loaded_identity_type( +pub(super) fn validate_loaded_identity_type( identity_type: IdentityType, identity: &Identity, ) -> Result<(), TaskError> { @@ -86,6 +86,15 @@ fn validate_loaded_identity_type( identity_id: identity.id(), }); } + if matches!( + identity_type, + IdentityType::Masternode | IdentityType::Evonode + ) && !identity_carries_owner_key(identity) + { + return Err(TaskError::IdentityIsNotMasternode { + identity_id: identity.id(), + }); + } Ok(()) } @@ -555,6 +564,7 @@ impl AppContext { if let Some(password) = encryption_password { self.protect_identity_keys(qualified_identity.identity.id(), password, None)?; } + self.clear_forgotten_identity_after_explicit_load(&identity_id)?; // Past the last fallible step: the node is stored with its keys as // requested. Anything that failed before this — including a key seal that @@ -863,6 +873,34 @@ mod tests { assert!(validate_loaded_identity_type(IdentityType::User, &identity).is_ok()); } + #[test] + fn masternode_load_rejects_regular_identity() { + let identity = identity_with_key_purpose(Purpose::AUTHENTICATION); + let expected_id = identity.id(); + + let error = validate_loaded_identity_type(IdentityType::Masternode, &identity) + .expect_err("a Masternode load must reject a regular identity"); + + assert!(matches!( + error, + TaskError::IdentityIsNotMasternode { identity_id } if identity_id == expected_id + )); + } + + #[test] + fn evonode_load_rejects_regular_identity() { + let identity = identity_with_key_purpose(Purpose::AUTHENTICATION); + let expected_id = identity.id(); + + let error = validate_loaded_identity_type(IdentityType::Evonode, &identity) + .expect_err("an Evonode load must reject a regular identity"); + + assert!(matches!( + error, + TaskError::IdentityIsNotMasternode { identity_id } if identity_id == expected_id + )); + } + #[test] fn node_load_accepts_identity_with_owner_key() { let identity = identity_with_key_purpose(Purpose::OWNER); diff --git a/src/backend_task/identity/load_identity_by_dpns_name.rs b/src/backend_task/identity/load_identity_by_dpns_name.rs index 669a778cd..648475bca 100644 --- a/src/backend_task/identity/load_identity_by_dpns_name.rs +++ b/src/backend_task/identity/load_identity_by_dpns_name.rs @@ -66,6 +66,8 @@ impl AppContext { Ok(None) => return Err(TaskError::IdentityNotFound), Err(e) => return Err(TaskError::from(e)), }; + super::load_identity::validate_loaded_identity_type(IdentityType::User, &identity)?; + let load_guard = self.begin_identity_load(identity_id, None)?; // Get the label from the document for display let label = domain_doc @@ -158,6 +160,8 @@ impl AppContext { // Insert qualified identity into the database self.insert_local_qualified_identity(&qualified_identity, &wallet_info)?; + self.clear_forgotten_identity_after_explicit_load(&identity_id)?; + load_guard.loaded(); Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) } diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 484d8bdfd..043eabdce 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -1,6 +1,7 @@ use super::{BackendTaskSuccessResult, IdentityIndex}; use crate::app::TaskResult; use crate::backend_task::error::TaskError; +use crate::backend_task::identity::IdentityDiscoveryMode; use crate::context::AppContext; use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, @@ -98,6 +99,8 @@ impl AppContext { let matching_identity_key_id = matching_identity_key.id(); let identity_id = identity.id(); + super::load_identity::validate_loaded_identity_type(IdentityType::User, &identity)?; + let load_guard = self.begin_identity_load(identity_id, None)?; let dpns_names_document_query = DocumentQuery { select: SelectProjection::documents(), @@ -265,6 +268,8 @@ impl AppContext { .identities .insert(identity_index, qualified_identity.identity.clone()); } + self.clear_forgotten_identity_after_explicit_load(&identity_id)?; + load_guard.loaded(); Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: 1 }) } @@ -281,7 +286,7 @@ impl AppContext { .discover_identities_gap_limited( &wallet_arc_ref.wallet, seed_identity_index, - true, + IdentityDiscoveryMode::ExplicitSearch, Some(&sender), ) .await?; diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 37334d1df..aa84abcff 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -14,6 +14,8 @@ mod transfer; mod unload_identity; mod withdraw_from_identity; +pub(crate) use discover_identities::IdentityDiscoveryMode; + use super::{BackendTaskSuccessResult, FeeResult, TaskError}; use crate::app::TaskResult; use crate::context::AppContext; diff --git a/src/backend_task/identity/unload_identity.rs b/src/backend_task/identity/unload_identity.rs index b432615ec..e7485cb88 100644 --- a/src/backend_task/identity/unload_identity.rs +++ b/src/backend_task/identity/unload_identity.rs @@ -7,38 +7,48 @@ use dash_sdk::platform::Identifier; use super::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::wallet_backend::poison::RwLockRecover; +use crate::wallet_backend::poison::{MutexRecover, RwLockRecover}; fn retain_other_identities(identities: &mut HashMap, identity_id: &Identifier) { identities.retain(|_, identity| identity.id() != *identity_id); } impl AppContext { - pub(super) fn unload_identity( - &self, - identity_id: Identifier, - ) -> Result { - self.delete_local_qualified_identity(&identity_id)?; - + pub(crate) fn reconcile_unloaded_identity_memory(&self, identity_id: &Identifier) { let wallets = self.wallets.read_recover(); for wallet in wallets.values() { - retain_other_identities(&mut wallet.write_recover().identities, &identity_id); + retain_other_identities(&mut wallet.write_recover().identities, identity_id); } drop(wallets); - if self.selected_identity_id() == Some(identity_id) { + if self.selected_identity_id() == Some(*identity_id) { self.set_selected_identity(None); } - let mut pending = self.pending_identity_selection.lock()?; - if *pending == Some(identity_id) { + let mut pending = self.pending_identity_selection.lock_recover(); + if *pending == Some(*identity_id) { *pending = None; } + } + + pub(super) fn unload_identity( + &self, + identity_id: Identifier, + ) -> Result { + let cleanup_error = match self.unload_local_qualified_identity(&identity_id) { + Ok(()) => None, + Err(error) if error.identity_was_removed() => Some(error), + Err(error) => return Err(error), + }; + self.reconcile_unloaded_identity_memory(&identity_id); tracing::info!( target = "backend_task::identity::unload_identity", identity = %identity_id, "Unloaded identity and its local device state", ); + if let Some(error) = cleanup_error { + return Err(error); + } Ok(BackendTaskSuccessResult::UnloadedIdentity(identity_id)) } } @@ -47,9 +57,12 @@ impl AppContext { mod tests { use super::*; use crate::context::test_support::test_app_context; + use crate::model::dashpay::ContactPrivateInfo; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::version::PlatformVersion; + use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; #[test] @@ -152,4 +165,126 @@ mod tests { ); backend.shutdown().await; } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn identity_unload_reconciles_memory_after_committed_cleanup_failure() { + use crate::app::TaskResult; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let platform_version = PlatformVersion::latest(); + let target_id = Identifier::from([0x51; 32]); + let contact_id = Identifier::from([0x52; 32]); + let target = Identity::create_basic_identity(target_id, platform_version) + .expect("create target identity"); + let mut wallet = Wallet::new_from_seed([0x53; 64], Network::Testnet, None, None) + .expect("build test wallet"); + wallet.identities.insert(3, target.clone()); + let wallet_seed_hash = wallet.seed_hash(); + let qualified_identity = QualifiedIdentity { + identity: target, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: Vec::new(), + associated_wallets: BTreeMap::from([( + wallet_seed_hash, + Arc::new(RwLock::new(wallet.clone())), + )]), + secret_access: Some(backend.secret_access()), + wallet_index: Some(3), + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + ctx.insert_local_qualified_identity(&qualified_identity, &Some((wallet_seed_hash, 3))) + .expect("insert target identity"); + ctx.wallets() + .write() + .expect("write wallets") + .insert(wallet_seed_hash, Arc::new(RwLock::new(wallet))); + ctx.set_selected_identity(Some(target_id)); + ctx.set_pending_identity_selection(target_id); + + backend + .dashpay_set_private_info( + &target_id, + &contact_id, + &ContactPrivateInfo { + nickname: "target contact".into(), + notes: "target note".into(), + is_hidden: false, + }, + ) + .expect("seed target owner overlay"); + let target_buf = target_id.to_buffer(); + let overlay_key = backend + .kv() + .list( + crate::wallet_backend::DetScope::Identity(&target_buf), + Some("det:dashpay:private:"), + ) + .expect("list target overlays") + .into_iter() + .next() + .expect("target overlay key"); + let persister_path = backend.spv_storage_dir().join("platform-wallet.sqlite"); + let fault_connection = + rusqlite::Connection::open(&persister_path).expect("open persister second handle"); + fault_connection + .execute_batch(&format!( + "CREATE TRIGGER fail_unload_overlay_delete + BEFORE DELETE ON meta_identity + WHEN OLD.identity_id = X'{}' AND OLD.key = '{}' + BEGIN + SELECT RAISE(FAIL, 'injected owner overlay delete failure'); + END;", + hex::encode(target_buf), + overlay_key.replace('\'', "''"), + )) + .expect("install owner-overlay delete trigger"); + + let error = ctx + .unload_identity(target_id) + .expect_err("cleanup failure must still reach the caller"); + assert!(matches!( + error, + TaskError::IdentityUnloadCleanupFailed { identity_id, .. } + if identity_id == target_id + )); + + let target_is_cached = { + let wallets = ctx.wallets().read().expect("read wallets"); + let wallet = wallets + .get(&wallet_seed_hash) + .expect("wallet remains") + .read() + .expect("read wallet"); + wallet + .identities + .values() + .any(|identity| identity.id() == target_id) + }; + assert!( + !target_is_cached, + "post-commit cleanup errors must still evict the wallet cache" + ); + assert_eq!(ctx.selected_identity_id(), None); + assert_eq!(ctx.take_pending_identity_selection(), None); + + fault_connection + .execute_batch("DROP TRIGGER fail_unload_overlay_delete;") + .expect("remove owner-overlay delete trigger"); + backend.shutdown().await; + } } diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 642a09f74..cb2364f81 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -69,6 +69,21 @@ fn top_up_err(source: KvAdapterError) -> TaskError { TaskError::TopUpHistoryStorage { source } } +fn keep_first_unload_cleanup_error( + cleanup_error: &mut Option, + identity_id: Identifier, + result: std::result::Result<(), TaskError>, +) { + if cleanup_error.is_none() { + *cleanup_error = result + .err() + .map(|source| TaskError::IdentityUnloadCleanupFailed { + identity_id, + source: Box::new(source), + }); + } +} + /// Merge `top_ups` into the stored history of `identity_id` (read-merge-write). /// /// Callers hold a partial view of the history — the top-up flow carries the @@ -929,6 +944,42 @@ impl AppContext { pub fn delete_local_qualified_identity( &self, identifier: &Identifier, + ) -> std::result::Result<(), TaskError> { + self.delete_local_qualified_identity_inner(identifier, false) + } + + /// Delete an identity and remember a wallet-derived user's unload choice. + pub(crate) fn unload_local_qualified_identity( + &self, + identifier: &Identifier, + ) -> std::result::Result<(), TaskError> { + self.delete_local_qualified_identity_inner(identifier, true) + } + + /// Whether automatic discovery must leave this identity unloaded. + pub(crate) fn is_identity_forgotten( + &self, + identifier: &Identifier, + ) -> std::result::Result { + self.db + .is_identity_forgotten(self.network, identifier) + .map_err(|source| TaskError::ForgottenIdentityStorage { source }) + } + + /// Clear the discovery block after a user-requested load succeeds. + pub(crate) fn clear_forgotten_identity_after_explicit_load( + &self, + identifier: &Identifier, + ) -> std::result::Result<(), TaskError> { + self.db + .clear_forgotten_identity(self.network, identifier) + .map_err(|source| TaskError::ForgottenIdentityStorage { source }) + } + + fn delete_local_qualified_identity_inner( + &self, + identifier: &Identifier, + remember_wallet_derived_unload: bool, ) -> std::result::Result<(), TaskError> { // The load registry provides the existing per-identity exclusive claim. let load_guard = self.begin_identity_load(*identifier, None)?; @@ -941,6 +992,13 @@ impl AppContext { } let kv = self.det_kv()?; let id = identifier.to_buffer(); + let should_remember_unload = remember_wallet_derived_unload + && kv + .get::(DetScope::Identity(&id), IDENTITY_KEY) + .map_err(identity_err)? + .is_some_and(|stored| { + stored.wallet_hash.is_some() && stored.wallet_index.is_some() + }); crate::backend_task::migration::finish_unwire::record_identity_deletion(self, id).map_err( |source| TaskError::IdentityDeletionMigrationRecord { source: Arc::new(source), @@ -949,24 +1007,28 @@ impl AppContext { let mut cleanup_error = None; match self.wallet_backend() { Ok(backend) => { - let mut record_cleanup_result = |result: std::result::Result<(), TaskError>| { - if cleanup_error.is_none() { - cleanup_error = - result - .err() - .map(|source| TaskError::IdentityUnloadCleanupFailed { - identity_id: *identifier, - source: Box::new(source), - }); - } - }; - - record_cleanup_result(backend.dashpay_clear_owner_overlays(identifier)); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_owner_overlays(identifier), + ); // Conversation/payment timestamps that are not keyed by this identity // may be shared; full-wallet teardown is the safe reclamation boundary. - record_cleanup_result(backend.dashpay_clear_identity_timestamps(identifier)); - record_cleanup_result(backend.dashpay_clear_identity_addr_map(identifier)); - record_cleanup_result(backend.identity_meta().delete(self.network, &id)); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_identity_timestamps(identifier), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_identity_addr_map(identifier), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.identity_meta().delete(self.network, &id), + ); } Err(TaskError::WalletBackendNotYetWired) => { tracing::warn!( @@ -976,15 +1038,35 @@ impl AppContext { } Err(error) => return Err(error), } + if should_remember_unload { + self.db + .record_forgotten_identity(self.network, identifier) + .map_err(|source| TaskError::ForgottenIdentityStorage { source })?; + } // Drop the identity from the index BEFORE the irreversible vault-key // clear: a fault in either of the next two steps must never leave a // "zombie" identity that is still visible but already missing its // keys. `clear_identity_vault_keys` must still run before // `purge_identity_scope`, since it reads the identity blob that // `purge_identity_scope` deletes. - index_remove_identity(&kv, &id)?; - self.clear_identity_vault_keys(&kv, &id)?; - purge_identity_scope(&kv, &id)?; + if let Err(error) = index_remove_identity(&kv, &id) { + if should_remember_unload { + self.db + .clear_forgotten_identity(self.network, identifier) + .map_err(|source| TaskError::ForgottenIdentityStorage { source })?; + } + return Err(error); + } + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + self.clear_identity_vault_keys(&kv, &id), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + purge_identity_scope(&kv, &id), + ); if let Some(error) = cleanup_error { return Err(error); } @@ -2239,7 +2321,7 @@ mod tests { /// simulated one — driving the actual `delete_local_qualified_identity` /// entry point end to end. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn code_001_a_deletion_fault_after_index_removal_leaves_no_visible_zombie() { + async fn deletion_fault_after_index_removal_leaves_no_visible_zombie() { use crate::app::TaskResult; use crate::context::test_support::test_app_context; use crate::utils::egui_mpsc::SenderAsync; diff --git a/src/context/wallet_lifecycle/bootstrap.rs b/src/context/wallet_lifecycle/bootstrap.rs index 90e1bc1ac..afaf8b9bd 100644 --- a/src/context/wallet_lifecycle/bootstrap.rs +++ b/src/context/wallet_lifecycle/bootstrap.rs @@ -2,6 +2,7 @@ //! managed identities, warming auth-key caches, and queuing identity discovery. use super::*; +use crate::backend_task::identity::IdentityDiscoveryMode; impl AppContext { /// Whether `wallet` still needs its bootstrap address set derived. @@ -446,7 +447,12 @@ impl AppContext { .subtasks .spawn_sync("all_wallets_identity_discovery", async move { if let Err(error) = ctx - .discover_identities_gap_limited(&wallet, 0, false, None) + .discover_identities_gap_limited( + &wallet, + 0, + IdentityDiscoveryMode::Background, + None, + ) .await { tracing::warn!( @@ -496,7 +502,7 @@ impl AppContext { } if let Err(error) = self - .discover_identities_gap_limited(wallet, 0, true, None) + .discover_identities_gap_limited(wallet, 0, IdentityDiscoveryMode::WalletUnlock, None) .await { tracing::warn!( diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index dfeea41e8..d4848e66b 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -82,7 +82,11 @@ impl AppContext { owner = %owner, "Identity private-key wipe failed during clear: {e:?}" ); - failures.push(e); + let underlying_error = match e { + TaskError::IdentityUnloadCleanupFailed { source, .. } => *source, + other => other, + }; + failures.push(underlying_error); } } } diff --git a/src/database/forgotten_identities.rs b/src/database/forgotten_identities.rs new file mode 100644 index 000000000..7f0d94a38 --- /dev/null +++ b/src/database/forgotten_identities.rs @@ -0,0 +1,96 @@ +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::platform::Identifier; +use rusqlite::{Connection, params}; + +use super::Database; + +impl Database { + /// Create the durable per-network identity-unload marker table. + pub(crate) fn initialize_forgotten_identities_table(conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS forgotten_identities ( + network TEXT NOT NULL, + identity_id BLOB NOT NULL CHECK (length(identity_id) = 32), + PRIMARY KEY (network, identity_id) + )", + [], + )?; + Ok(()) + } + + /// Record that automatic discovery must not restore an unloaded identity. + pub(crate) fn record_forgotten_identity( + &self, + network: Network, + identity_id: &Identifier, + ) -> rusqlite::Result<()> { + self.execute( + "INSERT OR IGNORE INTO forgotten_identities (network, identity_id) + VALUES (?1, ?2)", + params![network.to_string(), identity_id.to_buffer()], + )?; + Ok(()) + } + + /// Allow discovery after the user explicitly restores an identity. + pub(crate) fn clear_forgotten_identity( + &self, + network: Network, + identity_id: &Identifier, + ) -> rusqlite::Result<()> { + self.execute( + "DELETE FROM forgotten_identities + WHERE network = ?1 AND identity_id = ?2", + params![network.to_string(), identity_id.to_buffer()], + )?; + Ok(()) + } + + /// Whether an identity is deliberately unloaded on one network. + pub(crate) fn is_identity_forgotten( + &self, + network: Network, + identity_id: &Identifier, + ) -> rusqlite::Result { + let conn = self.locked_conn(); + conn.query_row( + "SELECT EXISTS( + SELECT 1 FROM forgotten_identities + WHERE network = ?1 AND identity_id = ?2 + )", + params![network.to_string(), identity_id.to_buffer()], + |row| row.get(0), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::test_helpers::create_test_database; + + #[test] + fn forgotten_identity_markers_are_durable_per_network() { + let db = create_test_database().expect("create database"); + let identity_id = Identifier::from([0x31; 32]); + + db.record_forgotten_identity(Network::Testnet, &identity_id) + .expect("record marker"); + + assert!( + db.is_identity_forgotten(Network::Testnet, &identity_id) + .expect("read testnet marker") + ); + assert!( + !db.is_identity_forgotten(Network::Mainnet, &identity_id) + .expect("read mainnet marker") + ); + + db.clear_forgotten_identity(Network::Testnet, &identity_id) + .expect("clear marker"); + assert!( + !db.is_identity_forgotten(Network::Testnet, &identity_id) + .expect("read cleared marker") + ); + } +} diff --git a/src/database/initialization.rs b/src/database/initialization.rs index b9da5686d..413139708 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -35,7 +35,7 @@ impl MigrationResultExt for rusqlite::Result { } } -pub const DEFAULT_DB_VERSION: u16 = 38; +pub const DEFAULT_DB_VERSION: u16 = 39; /// Minimal view of `.env` values the v34 migration needs. struct V34EnvSnapshot { @@ -239,6 +239,12 @@ impl Database { data_dir: Option<&Path>, ) -> Result<(), MigrationError> { match version { + 39 => { + Self::initialize_forgotten_identities_table(tx).migration_err( + "forgotten_identities", + "v39: create forgotten identity markers", + )?; + } 38 => { // Drop the retired `core_backend_mode` settings column. The // RPC/SPV backend selector it held was unwired in C3 (user @@ -742,8 +748,8 @@ impl Database { /// are created. Truly-fresh DET installs pass `false` so these dormant /// schemas never appear in `data.db`; legacy installs and the migration /// ladder still pass `true` so upgrade arms keep working. Always-present - /// tables (`settings`, `identity`, `platform_address_balances`) are - /// created regardless. + /// tables (`settings`, `forgotten_identities`, + /// `platform_address_balances`) are created regardless. pub(crate) fn create_tables(&self, include_legacy: bool) -> rusqlite::Result<()> { let conn = self.locked_conn(); // Create the settings table. @@ -761,6 +767,7 @@ impl Database { )", [], )?; + Self::initialize_forgotten_identities_table(&conn)?; if include_legacy { // Create the wallet table @@ -3125,6 +3132,49 @@ mod test { } } + mod v39 { + #[test] + fn v39_creates_forgotten_identity_markers_for_existing_databases() { + let tmp = tempfile::tempdir().unwrap(); + let db = super::super::Database::new(tmp.path().join("v38.db")).unwrap(); + db.execute( + "CREATE TABLE settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + database_version INTEGER NOT NULL + )", + [], + ) + .unwrap(); + db.execute( + "INSERT INTO settings (id, database_version) VALUES (1, 38)", + [], + ) + .unwrap(); + assert!( + !db.table_exists(&db.locked_conn(), "forgotten_identities") + .unwrap() + ); + + db.try_perform_migration(38, 39, None).unwrap(); + + assert_eq!(db.db_schema_version().unwrap(), 39); + assert!( + db.table_exists(&db.locked_conn(), "forgotten_identities") + .unwrap() + ); + let columns: i64 = db + .locked_conn() + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('forgotten_identities') + WHERE name IN ('network', 'identity_id')", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(columns, 2); + } + } + // ---------- T-DEV-01: legacy CREATE TABLE gating ---------- /// Helper: assert that a table does NOT exist in the database. diff --git a/src/database/mod.rs b/src/database/mod.rs index ffa200211..b0b163c59 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,6 +1,7 @@ mod initialization; #[cfg(test)] pub(crate) use initialization::DEFAULT_DB_VERSION; +mod forgotten_identities; pub(crate) mod legacy_import; mod settings; mod single_key_wallet; diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 343b43b9b..3721835f7 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -885,36 +885,53 @@ impl IdentitiesScreen { if let Some(identity_to_remove) = self.identity_to_remove.take() { let identity_id = identity_to_remove.identity.id(); - match self + let deletion_result = self .app_context - .delete_local_qualified_identity(&identity_id) - { - Ok(_) => { - let mut lock = self.identities.lock_recover(); - lock.shift_remove(&identity_id); - } - Err(e) => { - tracing::warn!( - "Failed to delete identity from database: {}", - e - ); - MessageBanner::set_global( - self.app_context.egui_ctx(), - format!("Failed to remove identity: {}", e), - MessageType::Error, - ) - .disable_auto_dismiss(); - } + .unload_local_qualified_identity(&identity_id); + let identity_was_removed = match &deletion_result { + Ok(()) => true, + Err(error) => error.identity_was_removed(), + }; + if identity_was_removed { + self.app_context + .reconcile_unloaded_identity_memory(&identity_id); + self.identities.lock_recover().shift_remove(&identity_id); + } + if let Err(error) = deletion_result { + tracing::warn!( + error = ?error, + "Identity removal did not finish cleanly" + ); + let message = if identity_was_removed { + "The identity was removed, but some local data could not be cleaned up. Try loading and unloading it again." + } else { + "This identity could not be removed from this device. Wait a moment and try again." + }; + let banner = MessageBanner::set_global( + self.app_context.egui_ctx(), + message, + MessageType::Error, + ); + banner.with_details(error); + banner.disable_auto_dismiss(); } if let Some((voter_identity, _)) = &identity_to_remove.associated_voter_identity { let voter_identity_id = voter_identity.id(); - if let Err(e) = self + let voter_result = self .app_context - .delete_local_qualified_identity(&voter_identity_id) + .delete_local_qualified_identity(&voter_identity_id); + if voter_result.as_ref().is_ok() + || voter_result + .as_ref() + .is_err_and(|error| error.identity_was_removed()) { + self.app_context + .reconcile_unloaded_identity_memory(&voter_identity_id); + } + if let Err(e) = voter_result { tracing::warn!( "Failed to delete voter identity from database: {}", e diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 36c8aa9d3..cc21b4e8f 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -536,7 +536,8 @@ impl ScreenLike for IdentityHubScreen { self.settings_tab.on_profile_saved(); } } - BackendTaskSuccessResult::UnloadedIdentity(_) => { + BackendTaskSuccessResult::UnloadedIdentity(identity_id) => { + self.profile_cache.remove_identity(identity_id); MessageBanner::set_global( self.app_context.egui_ctx(), "This identity was unloaded from this device.", diff --git a/src/ui/identity/profile_cache.rs b/src/ui/identity/profile_cache.rs index 71547a3fa..b33b3f82e 100644 --- a/src/ui/identity/profile_cache.rs +++ b/src/ui/identity/profile_cache.rs @@ -42,9 +42,13 @@ pub struct ProfileCache { requested: HashSet, /// Identity of the in-flight load. The result variant carries no owner id, /// so it is associated with this id on arrival. - in_flight: Option, + in_flight: Option<(Identifier, u64, u64)>, /// Identities a tab asked for this frame that still need a load dispatched. wanted: Vec, + /// Invalidates every request dispatched before a full reset. + cache_generation: u64, + /// Invalidates requests dispatched before one identity was unloaded. + identity_generations: HashMap, } impl ProfileCache { @@ -77,7 +81,8 @@ impl ProfileCache { }; let id = identity.identity.id(); self.requested.insert(id); - self.in_flight = Some(id); + let identity_generation = self.identity_generations.get(&id).copied().unwrap_or(0); + self.in_flight = Some((id, self.cache_generation, identity_generation)); AppAction::BackendTask(BackendTask::DashPayTask(Box::new( DashPayTask::LoadProfile { identity }, ))) @@ -89,9 +94,15 @@ impl ProfileCache { let BackendTaskSuccessResult::DashPayProfile(data) = result else { return false; }; - let Some(id) = self.in_flight.take() else { + let Some((id, cache_generation, identity_generation)) = self.in_flight.take() else { return false; }; + self.requested.remove(&id); + let is_current = cache_generation == self.cache_generation + && identity_generation == self.identity_generations.get(&id).copied().unwrap_or(0); + if !is_current { + return true; + } let fields = data .clone() .map(|(display_name, bio, avatar_url)| ProfileFields { @@ -103,11 +114,135 @@ impl ProfileCache { true } + /// Remove one identity's cached and queued profile state. + /// + /// An already-dispatched request remains the single in-flight operation, + /// but its generation is invalidated so a late response is discarded. + pub fn remove_identity(&mut self, identity_id: &Identifier) { + self.loaded.remove(identity_id); + self.requested.remove(identity_id); + self.wanted + .retain(|identity| identity.identity.id() != *identity_id); + let generation = self.identity_generations.entry(*identity_id).or_default(); + *generation = generation.wrapping_add(1); + } + /// Drop cached state and pending loads so a refresh re-resolves profiles. pub fn reset(&mut self) { self.loaded.clear(); self.requested.clear(); - self.in_flight = None; self.wanted.clear(); + self.in_flight = None; + self.cache_generation = self.cache_generation.wrapping_add(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::qualified_identity::{IdentityStatus, IdentityType}; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identity; + use std::collections::BTreeMap; + + fn qualified_identity(id: Identifier) -> QualifiedIdentity { + QualifiedIdentity { + identity: Identity::create_basic_identity(id, PlatformVersion::latest()) + .expect("create identity"), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: Vec::new(), + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + #[test] + fn late_profile_result_after_unload_does_not_repopulate_cache() { + let id = Identifier::from([0x41; 32]); + let identity = qualified_identity(id); + let mut cache = ProfileCache::default(); + + assert!(cache.get_or_request(&identity).is_none()); + assert!(matches!( + cache.dispatch_pending(), + AppAction::BackendTask(BackendTask::DashPayTask(_)) + )); + cache.loaded.insert( + id, + Some(ProfileFields { + display_name: "Previously cached".to_string(), + ..Default::default() + }), + ); + + cache.remove_identity(&id); + assert!( + cache.record_result(&BackendTaskSuccessResult::DashPayProfile(Some(( + "Stale name".to_string(), + "Stale bio".to_string(), + "https://example.invalid/stale.png".to_string(), + )))) + ); + + assert!( + !cache.loaded.contains_key(&id), + "a response started before unload must stay discarded" + ); + + assert!(cache.get_or_request(&identity).is_none()); + assert!(matches!( + cache.dispatch_pending(), + AppAction::BackendTask(BackendTask::DashPayTask(_)) + )); + assert!( + cache.record_result(&BackendTaskSuccessResult::DashPayProfile(Some(( + "Fresh name".to_string(), + "Fresh bio".to_string(), + "https://example.invalid/fresh.png".to_string(), + )))) + ); + assert_eq!( + cache + .loaded + .get(&id) + .and_then(Option::as_ref) + .map(|fields| fields.display_name.as_str()), + Some("Fresh name"), + "the next request after reload must populate normally" + ); + } + + #[test] + fn reset_unblocks_dispatch_after_profile_load_error() { + let failed_identity = qualified_identity(Identifier::from([0x42; 32])); + let next_identity = qualified_identity(Identifier::from([0x43; 32])); + let mut cache = ProfileCache::default(); + + assert!(cache.get_or_request(&failed_identity).is_none()); + assert!(matches!( + cache.dispatch_pending(), + AppAction::BackendTask(BackendTask::DashPayTask(_)) + )); + + cache.reset(); + + assert!(cache.get_or_request(&next_identity).is_none()); + assert!( + matches!( + cache.dispatch_pending(), + AppAction::BackendTask(BackendTask::DashPayTask(_)) + ), + "reset must abandon an unresolved request so another identity can load" + ); } } diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 311b34535..cc5860c57 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -989,27 +989,49 @@ impl MasternodeDetailView { } /// Delete the node and its associated voter identity from local storage. - /// On the primary delete failing, surface an actionable error banner rather - /// than failing silently, and keep the detail view open so the user can - /// retry. The secondary voter-identity delete failing is non-fatal (the node - /// is already gone) and only logged. + /// Keep the detail view open when storage deletion does not happen. If only + /// follow-up cleanup fails, reconcile memory, close the removed view, and + /// surface the cleanup error. Voter cleanup failures remain non-fatal. fn remove_node(&self, ctx: &egui::Context) -> bool { let node_id = self.identity.identity.id(); - if let Err(e) = self.app_context.delete_local_qualified_identity(&node_id) { - MessageBanner::set_global( - ctx, - "This masternode couldn't be removed from this device. Try again in a moment.", - MessageType::Error, - ) - .with_details(e); - return false; - } - if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() - && let Err(e) = self + match self.app_context.unload_local_qualified_identity(&node_id) { + Ok(()) => self .app_context - .delete_local_qualified_identity(&voter.id()) - { - tracing::warn!("Failed to remove voter identity: {e}"); + .reconcile_unloaded_identity_memory(&node_id), + Err(error) if error.identity_was_removed() => { + self.app_context + .reconcile_unloaded_identity_memory(&node_id); + MessageBanner::set_global( + ctx, + "The masternode was removed, but some local data could not be cleaned up. Load and remove it again to retry.", + MessageType::Error, + ) + .with_details(error); + } + Err(error) => { + MessageBanner::set_global( + ctx, + "This masternode couldn't be removed from this device. Try again in a moment.", + MessageType::Error, + ) + .with_details(error); + return false; + } + } + if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() { + let voter_id = voter.id(); + let voter_result = self.app_context.delete_local_qualified_identity(&voter_id); + if voter_result.as_ref().is_ok() + || voter_result + .as_ref() + .is_err_and(|error| error.identity_was_removed()) + { + self.app_context + .reconcile_unloaded_identity_memory(&voter_id); + } + if let Err(error) = voter_result { + tracing::warn!(error = ?error, "Failed to remove voter identity"); + } } true } From 06e7a9a76a9f3d4c0dc034384f6c25fd56faeeb5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:33:10 +0000 Subject: [PATCH 14/46] fix: address fresh coderabbitai findings on PR #925 (load-guard reporting, cleanup conflation) - Extract begin_identity_load_and_validate_type / finish_identity_load_after_persist (src/backend_task/identity/load_guard.rs) so the load-registry guard is always claimed before any fallible validation, and a post-persist forgotten-marker cleanup failure no longer discards an already-successful load as a reported failure. Migrated discover_identities.rs's persist_discovered_identity, load_identity_by_dpns_name.rs, and load_identity_from_wallet.rs onto these shared helpers, fixing the same ordering/discards-success bug in all three. - persist_discovered_identity now marks the guard loaded() on its intentional forgotten-identity skip too, instead of letting Drop report it as Failed. - remove_identity() (RemovedIdentities) now reports primary-identity cleanup failure separately from associated/voter-identity cleanup failure instead of conflating them into one flag; identities_screen.rs shows 4 distinct banner messages for the resulting combinations instead of always blaming the voter identity. - load_identity.rs is intentionally untouched: its own trailing cleanup-failure handling is an already-reviewed, accepted tradeoff from an earlier round. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Codex Sol --- .../identity/discover_identities.rs | 42 ++++- src/backend_task/identity/load_guard.rs | 123 ++++++++++++++ .../identity/load_identity_by_dpns_name.rs | 7 +- .../identity/load_identity_from_wallet.rs | 7 +- src/backend_task/identity/mod.rs | 1 + src/backend_task/identity/remove_identity.rs | 150 +++++++++++++++++- src/backend_task/mod.rs | 1 + src/context/identity_load_registry.rs | 13 ++ src/ui/identities/identities_screen.rs | 68 ++++++-- 9 files changed, 384 insertions(+), 28 deletions(-) create mode 100644 src/backend_task/identity/load_guard.rs diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index e1ac18e42..eccceb573 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -318,6 +318,7 @@ impl AppContext { let identity_id = qualified_identity.identity.id(); let load_guard = self.begin_identity_load(identity_id, None)?; if self.is_identity_forgotten(&identity_id)? && !explicit_reload { + load_guard.loaded(); return Ok(false); } @@ -338,9 +339,10 @@ impl AppContext { } } if explicit_reload { - self.clear_forgotten_identity_after_explicit_load(&identity_id)?; + self.finish_identity_load_after_persist(&identity_id, load_guard); + } else { + load_guard.loaded(); } - load_guard.loaded(); Ok(true) } @@ -507,6 +509,7 @@ impl AppContext { mod tests { use super::*; use crate::app::TaskResult; + use crate::context::identity_load_registry::IdentityLoadPhase; use crate::context::test_support::test_app_context; use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; use crate::utils::egui_mpsc::SenderAsync; @@ -574,6 +577,11 @@ mod tests { .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, false) .expect("simulate discovery persistence"); assert!(!stored, "discovery must skip a forgotten identity"); + assert_eq!( + ctx.latest_identity_load_phase(&identity_id), + Some(IdentityLoadPhase::Loaded), + "intentionally skipping a forgotten identity is a successful load no-op" + ); assert!( ctx.get_identity_by_id(&identity_id) .expect("read identity") @@ -594,7 +602,7 @@ mod tests { ctx.delete_local_qualified_identity(&identity_id) .expect("remove identity without recording another unload"); assert!( - ctx.persist_discovered_identity(identity, wallet_seed_hash, 4, false) + ctx.persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, false) .expect("simulate discovery after explicit reload"), "discovery must work normally after the explicit load clears the marker" ); @@ -604,6 +612,34 @@ mod tests { .is_some() ); + ctx.db() + .record_forgotten_identity(Network::Testnet, &identity_id) + .expect("record forgotten marker"); + ctx.db() + .execute( + "CREATE TRIGGER fail_discovery_marker_cleanup + BEFORE DELETE ON forgotten_identities + BEGIN + SELECT RAISE(FAIL, 'injected discovery marker cleanup failure'); + END;", + [], + ) + .expect("install marker cleanup failure trigger"); + assert!( + ctx.persist_discovered_identity(identity, wallet_seed_hash, 4, true) + .expect("persist despite marker cleanup failure"), + "durable persistence remains successful when marker cleanup fails" + ); + assert_eq!( + ctx.latest_identity_load_phase(&identity_id), + Some(IdentityLoadPhase::Loaded) + ); + assert!( + ctx.is_identity_forgotten(&identity_id) + .expect("read retained marker"), + "the injected cleanup fault must leave the marker in place" + ); + backend.shutdown().await; } } diff --git a/src/backend_task/identity/load_guard.rs b/src/backend_task/identity/load_guard.rs new file mode 100644 index 000000000..61334452b --- /dev/null +++ b/src/backend_task/identity/load_guard.rs @@ -0,0 +1,123 @@ +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::context::identity_load_registry::{IdentityLoadGuard, IdentityLoadToken}; +use crate::model::qualified_identity::IdentityType; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::{Identifier, Identity}; + +impl AppContext { + pub(super) fn begin_identity_load_and_validate_type( + &self, + identity_type: IdentityType, + identity: &Identity, + load_token: Option, + ) -> Result { + let load_guard = self.begin_identity_load(identity.id(), load_token)?; + super::load_identity::validate_loaded_identity_type(identity_type, identity)?; + Ok(load_guard) + } + + pub(super) fn finish_identity_load_after_persist( + &self, + identity_id: &Identifier, + load_guard: IdentityLoadGuard, + ) { + if let Err(error) = self.clear_forgotten_identity_after_explicit_load(identity_id) { + tracing::warn!( + ?error, + identity_id = %identity_id, + "Persisted identity but could not clear its forgotten marker" + ); + } + load_guard.loaded(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::identity_load_registry::IdentityLoadPhase; + use crate::context::test_support::test_app_context; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Purpose; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, + }; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::IdentityPublicKey; + use std::collections::BTreeMap; + + #[test] + fn rejected_identity_type_reports_failed_load() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let platform_version = PlatformVersion::latest(); + let mut owner_key = IdentityPublicKey::random_key(1, Some(1), platform_version); + owner_key.set_purpose(Purpose::OWNER); + let identity = Identity::new_with_id_and_keys( + Identifier::from([0x71; 32]), + BTreeMap::from([(owner_key.id(), owner_key)]), + platform_version, + ) + .expect("identity"); + let identity_id = identity.id(); + let token = ctx + .mark_identity_load_submitted(identity_id) + .expect("submit load"); + + let error = ctx + .begin_identity_load_and_validate_type(IdentityType::User, &identity, Some(token)) + .expect_err("a user load must reject an identity with an owner key"); + + assert!(matches!( + error, + TaskError::IdentityIsMasternode { + identity_id: rejected_id + } if rejected_id == identity_id + )); + assert_eq!( + ctx.identity_load_phase(&identity_id, token), + Some(IdentityLoadPhase::Failed), + "type validation must happen after the load becomes reportable" + ); + } + + #[test] + fn cleanup_failure_after_persist_still_reports_loaded() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let identity_id = Identifier::from([0x72; 32]); + ctx.db() + .record_forgotten_identity(Network::Testnet, &identity_id) + .expect("record forgotten marker"); + ctx.db() + .execute( + "CREATE TRIGGER fail_forgotten_marker_cleanup + BEFORE DELETE ON forgotten_identities + BEGIN + SELECT RAISE(FAIL, 'injected forgotten marker cleanup failure'); + END;", + [], + ) + .expect("install cleanup failure trigger"); + let token = ctx + .mark_identity_load_submitted(identity_id) + .expect("submit load"); + let load_guard = ctx + .begin_identity_load(identity_id, Some(token)) + .expect("claim load"); + + ctx.finish_identity_load_after_persist(&identity_id, load_guard); + + assert_eq!( + ctx.identity_load_phase(&identity_id, token), + Some(IdentityLoadPhase::Loaded), + "marker cleanup is non-essential after durable persistence" + ); + assert!( + ctx.is_identity_forgotten(&identity_id) + .expect("read retained marker"), + "the injected cleanup fault must leave the marker in place" + ); + } +} diff --git a/src/backend_task/identity/load_identity_by_dpns_name.rs b/src/backend_task/identity/load_identity_by_dpns_name.rs index 648475bca..205048bd0 100644 --- a/src/backend_task/identity/load_identity_by_dpns_name.rs +++ b/src/backend_task/identity/load_identity_by_dpns_name.rs @@ -66,8 +66,8 @@ impl AppContext { Ok(None) => return Err(TaskError::IdentityNotFound), Err(e) => return Err(TaskError::from(e)), }; - super::load_identity::validate_loaded_identity_type(IdentityType::User, &identity)?; - let load_guard = self.begin_identity_load(identity_id, None)?; + let load_guard = + self.begin_identity_load_and_validate_type(IdentityType::User, &identity, None)?; // Get the label from the document for display let label = domain_doc @@ -160,8 +160,7 @@ impl AppContext { // Insert qualified identity into the database self.insert_local_qualified_identity(&qualified_identity, &wallet_info)?; - self.clear_forgotten_identity_after_explicit_load(&identity_id)?; - load_guard.loaded(); + self.finish_identity_load_after_persist(&identity_id, load_guard); Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) } diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 043eabdce..5990cc7b8 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -99,8 +99,8 @@ impl AppContext { let matching_identity_key_id = matching_identity_key.id(); let identity_id = identity.id(); - super::load_identity::validate_loaded_identity_type(IdentityType::User, &identity)?; - let load_guard = self.begin_identity_load(identity_id, None)?; + let load_guard = + self.begin_identity_load_and_validate_type(IdentityType::User, &identity, None)?; let dpns_names_document_query = DocumentQuery { select: SelectProjection::documents(), @@ -268,8 +268,7 @@ impl AppContext { .identities .insert(identity_index, qualified_identity.identity.clone()); } - self.clear_forgotten_identity_after_explicit_load(&identity_id)?; - load_guard.loaded(); + self.finish_identity_load_after_persist(&identity_id, load_guard); Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: 1 }) } diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 48e1c09c9..854b12a93 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -1,6 +1,7 @@ mod add_key_to_identity; mod auth_pubkey_resolve; mod discover_identities; +mod load_guard; mod load_identity; mod load_identity_by_dpns_name; mod load_identity_from_wallet; diff --git a/src/backend_task/identity/remove_identity.rs b/src/backend_task/identity/remove_identity.rs index ca416efe3..84d1a4644 100644 --- a/src/backend_task/identity/remove_identity.rs +++ b/src/backend_task/identity/remove_identity.rs @@ -31,7 +31,8 @@ impl AppContext { self.reconcile_unloaded_identity_memory(&identity_id); let mut removed_identity_ids = vec![identity_id]; - let mut associated_cleanup_failed = cleanup_error.is_some(); + let primary_cleanup_failed = cleanup_error.is_some(); + let mut associated_cleanup_failed = false; if let Some(voter_id) = associated_voter_identity_id.filter(|id| *id != identity_id) { match self.unload_local_qualified_identity(&voter_id) { Ok(()) => { @@ -56,6 +57,7 @@ impl AppContext { Ok(BackendTaskSuccessResult::RemovedIdentities { identity_ids: removed_identity_ids, + primary_cleanup_failed, associated_cleanup_failed, }) } @@ -65,12 +67,149 @@ impl AppContext { mod tests { use super::*; use crate::context::test_support::test_app_context; + use crate::model::dashpay::ContactPrivateInfo; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::IdentityPublicKey; + use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; + fn qualified_identity( + identity: Identity, + associated_voter_identity: Option<(Identity, IdentityPublicKey)>, + secret_access: crate::wallet_backend::SecretAccess, + ) -> QualifiedIdentity { + QualifiedIdentity { + identity, + associated_voter_identity, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: Default::default(), + dpns_names: Vec::new(), + associated_wallets: BTreeMap::new(), + secret_access: Some(secret_access), + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + async fn removal_result_with_cleanup_failure( + fail_associated_cleanup: bool, + ) -> BackendTaskSuccessResult { + use crate::app::TaskResult; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let platform_version = PlatformVersion::latest(); + let target_id = Identifier::from([0x81; 32]); + let voter_id = Identifier::from([0x82; 32]); + let target = Identity::create_basic_identity(target_id, platform_version) + .expect("create target identity"); + let voter = Identity::create_basic_identity(voter_id, platform_version) + .expect("create voter identity"); + let voter_key = IdentityPublicKey::random_key(1, Some(1), platform_version); + let associated_voter_identity = fail_associated_cleanup.then(|| (voter.clone(), voter_key)); + let target = qualified_identity(target, associated_voter_identity, backend.secret_access()); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + if fail_associated_cleanup { + let voter = qualified_identity(voter, None, backend.secret_access()); + ctx.insert_local_qualified_identity(&voter, &None) + .expect("insert voter identity"); + } + + let fault_id = if fail_associated_cleanup { + voter_id + } else { + target_id + }; + let contact_id = Identifier::from([0x83; 32]); + backend + .dashpay_set_private_info( + &fault_id, + &contact_id, + &ContactPrivateInfo { + nickname: "cleanup fault".into(), + notes: "cleanup fault".into(), + is_hidden: false, + }, + ) + .expect("seed owner overlay"); + let fault_buf = fault_id.to_buffer(); + let overlay_key = backend + .kv() + .list( + crate::wallet_backend::DetScope::Identity(&fault_buf), + Some("det:dashpay:private:"), + ) + .expect("list owner overlays") + .into_iter() + .next() + .expect("owner overlay key"); + let persister_path = backend.spv_storage_dir().join("platform-wallet.sqlite"); + let fault_connection = + rusqlite::Connection::open(&persister_path).expect("open persister second handle"); + fault_connection + .execute_batch(&format!( + "CREATE TRIGGER fail_remove_identity_overlay_delete + BEFORE DELETE ON meta_identity + WHEN OLD.identity_id = X'{}' AND OLD.key = '{}' + BEGIN + SELECT RAISE(FAIL, 'injected owner overlay delete failure'); + END;", + hex::encode(fault_buf), + overlay_key.replace('\'', "''"), + )) + .expect("install owner-overlay delete trigger"); + + let result = ctx + .remove_identity(target_id) + .expect("committed cleanup faults remain a successful removal result"); + + fault_connection + .execute_batch("DROP TRIGGER fail_remove_identity_overlay_delete;") + .expect("remove owner-overlay delete trigger"); + backend.shutdown().await; + result + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn remove_identity_reports_primary_and_associated_cleanup_failures_independently() { + let primary_failure = removal_result_with_cleanup_failure(false).await; + assert!(matches!( + primary_failure, + BackendTaskSuccessResult::RemovedIdentities { + primary_cleanup_failed: true, + associated_cleanup_failed: false, + .. + } + )); + + let associated_failure = removal_result_with_cleanup_failure(true).await; + assert!(matches!( + associated_failure, + BackendTaskSuccessResult::RemovedIdentities { + primary_cleanup_failed: false, + associated_cleanup_failed: true, + .. + } + )); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remove_identity_reconciles_wallet_cache_and_selection() { use crate::app::TaskResult; @@ -109,8 +248,13 @@ mod tests { assert!(matches!( &result, - BackendTaskSuccessResult::RemovedIdentities { identity_ids, associated_cleanup_failed } - if identity_ids == &vec![target_id] && !associated_cleanup_failed + BackendTaskSuccessResult::RemovedIdentities { + identity_ids, + primary_cleanup_failed, + associated_cleanup_failed, + } if identity_ids == &vec![target_id] + && !primary_cleanup_failed + && !associated_cleanup_failed )); let (target_evicted, cached_sibling) = { let wallets = ctx.wallets().read().expect("read wallets"); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index fe2a9a743..ee0edabea 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -717,6 +717,7 @@ pub enum BackendTaskSuccessResult { }, RemovedIdentities { identity_ids: Vec, + primary_cleanup_failed: bool, associated_cleanup_failed: bool, }, RefreshedIdentity(QualifiedIdentity), diff --git a/src/context/identity_load_registry.rs b/src/context/identity_load_registry.rs index d3d0a5e72..a1e88652d 100644 --- a/src/context/identity_load_registry.rs +++ b/src/context/identity_load_registry.rs @@ -288,6 +288,19 @@ impl AppContext { .filter(|record| record.token == token) .map(|record| record.phase) } + + #[cfg(test)] + pub(crate) fn latest_identity_load_phase( + &self, + identity_id: &Identifier, + ) -> Option { + self.identity_loads + .lock() + .unwrap_or_else(|e| e.into_inner()) + .records + .get(identity_id) + .map(|record| record.phase) + } } #[cfg(test)] diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index be812f664..18c0fa8a9 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -41,6 +41,30 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; +fn identity_removal_message( + primary_cleanup_failed: bool, + associated_cleanup_failed: bool, +) -> (&'static str, MessageType) { + match (primary_cleanup_failed, associated_cleanup_failed) { + (false, false) => ( + "The identity was removed from this device.", + MessageType::Success, + ), + (true, false) => ( + "The identity was removed, but some local data could not be cleaned up. Load and remove it again to retry.", + MessageType::Warning, + ), + (false, true) => ( + "The identity was removed, but its associated voter identity could not be removed. Retry after restarting the app.", + MessageType::Warning, + ), + (true, true) => ( + "The identity was removed, but some local data could not be cleaned up and its associated voter identity could not be removed. Restart the app, then load and remove the identity again to retry.", + MessageType::Warning, + ), + } +} + #[derive(Clone, Copy, PartialEq, Eq)] enum IdentitiesSortColumn { Alias, @@ -1120,6 +1144,7 @@ impl ScreenLike for IdentitiesScreen { } crate::ui::BackendTaskSuccessResult::RemovedIdentities { identity_ids, + primary_cleanup_failed, associated_cleanup_failed, } => { let mut identities = self.identities.lock_recover(); @@ -1127,19 +1152,12 @@ impl ScreenLike for IdentitiesScreen { identities.shift_remove(&identity_id); } drop(identities); - if associated_cleanup_failed { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "The identity was removed, but its associated voter identity could not be removed. Retry after restarting the app.", - MessageType::Warning, - ) - .disable_auto_dismiss(); - } else { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "The identity was removed from this device.", - MessageType::Success, - ); + let (message, message_type) = + identity_removal_message(primary_cleanup_failed, associated_cleanup_failed); + let banner = + MessageBanner::set_global(self.app_context.egui_ctx(), message, message_type); + if primary_cleanup_failed || associated_cleanup_failed { + banner.disable_auto_dismiss(); } } _ => {} @@ -1249,12 +1267,34 @@ impl ScreenLike for IdentitiesScreen { #[cfg(test)] mod tests { - use super::render_identity_name_cell; + use super::{identity_removal_message, render_identity_name_cell}; use crate::model::contested_name::PendingUsername; use crate::ui::components::pill::PENDING_USERNAME_PILL_LABEL; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; + #[test] + fn identity_removal_messages_distinguish_cleanup_outcomes() { + let (message, message_type) = identity_removal_message(false, false); + assert_eq!(message, "The identity was removed from this device."); + assert_eq!(message_type, crate::ui::MessageType::Success); + + let (message, message_type) = identity_removal_message(true, false); + assert!(message.contains("some local data could not be cleaned up")); + assert!(!message.contains("associated voter identity")); + assert_eq!(message_type, crate::ui::MessageType::Warning); + + let (message, message_type) = identity_removal_message(false, true); + assert!(!message.contains("some local data could not be cleaned up")); + assert!(message.contains("associated voter identity could not be removed")); + assert_eq!(message_type, crate::ui::MessageType::Warning); + + let (message, message_type) = identity_removal_message(true, true); + assert!(message.contains("some local data could not be cleaned up")); + assert!(message.contains("associated voter identity could not be removed")); + assert_eq!(message_type, crate::ui::MessageType::Warning); + } + /// The Identities list Name cell shows the identity's name and, when a DPNS /// registration is pending, a "Pending" pill beside it. #[test] From c2c78ee4586239617f3363cd518651dd716bf4db Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:55:35 +0000 Subject: [PATCH 15/46] fix: close discovery/unload race, load-masking, and removal-reporting gaps (PR #925 review) Addresses 3 blocking + 1 suggestion finding from thepastaclaw's latest review pass on PR #925 (independently re-verified against current HEAD before fixing): - discover_identities.rs: keep the per-identity IdentityLoadGuard claim held through the wallet-cache insertion, not just the persist step, so a concurrent unload can no longer be resurrected by a late discovery write. Adds a concurrency regression test. - load_identity.rs: route forgotten-marker cleanup through the existing finish_identity_load_after_persist best-effort helper (already used by the other three load paths) instead of a bare `?`, so a cleanup failure no longer masks an already-durably-committed load as Failed. - remove_identity.rs / backend_task/mod.rs / identities_screen.rs: split associated_cleanup_failed into separate "removed but residue left behind" vs "genuinely not removed" outcomes (new associated_removal_failed field) so the removal banner and retry guidance are accurate in both cases. - hub_screen.rs: invalidate the profile cache on a committed IdentityUnloadCleanupFailed error too, not just the UnloadedIdentity success path, so a later reload can't serve a stale cached profile. Verification performed by the coordinator, independently, in an isolated worktree routed through the cached cargo wrapper: targeted tests for all five touched modules pass with new test names confirmed in the ledger log, a scoped lint pass is clean, and formatting checks clean. Implementation by Codex Sol (gpt-5.6-sol, high effort); reviewed, independently re-verified, and committed by the coordinator. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 +- .../identity/discover_identities.rs | 132 ++++++++++++++---- src/backend_task/identity/load_identity.rs | 65 ++++++++- src/backend_task/identity/remove_identity.rs | 69 ++++++++- src/backend_task/mod.rs | 1 + src/ui/identities/identities_screen.rs | 61 +++++--- src/ui/identity/hub_screen.rs | 42 ++++++ 7 files changed, 323 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67945d22d..b4189feaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,9 +56,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Identity unload and reload follow-ups for #889 / PR #925**: node load forms now reject regular identities, wallet discovery keeps deliberately unloaded - identities unloaded until the user explicitly loads them again, partial - cleanup failures still reconcile the app's active identity state, and late - DashPay profile responses can no longer restore stale profile data. + identities unloaded until the user explicitly loads them again even when + discovery overlaps an unload, successful loads are not reported as failed + when marker cleanup leaves recoverable residue, associated voter removal + outcomes are reported accurately, partial cleanup failures still reconcile + the app's active identity state, and late DashPay profile responses can no + longer restore stale profile data. - **Wallet rename consistency**: renaming a wallet no longer overwrites other saved wallet details when metadata cannot be read. Overlapping renames and diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index eccceb573..da98ec1ca 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -2,6 +2,7 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::context::identity_load_registry::IdentityLoadGuard; use crate::model::identity_discovery::{ DiscoverySummary, IDENTITY_GAP_LIMIT, IDENTITY_SCAN_HARD_CAP, should_continue_scan, }; @@ -282,24 +283,31 @@ impl AppContext { ) .await?; - if !self.persist_discovered_identity( + let explicit_reload = mode.explicitly_reloads_forgotten(); + let Some(load_guard) = self.persist_discovered_identity( qualified_identity.clone(), seed_hash, identity_index, - mode.explicitly_reloads_forgotten(), - )? { + explicit_reload, + )? + else { tracing::debug!( identity_id = %identity_id, "Skipped a discovered identity that the user unloaded" ); return Ok(false); - } + }; if let Ok(mut wallet_guard) = wallet.write() { wallet_guard .identities .insert(identity_index, qualified_identity.identity.clone()); } + if explicit_reload { + self.finish_identity_load_after_persist(&identity_id, load_guard); + } else { + load_guard.loaded(); + } tracing::info!( identity_id = %identity_id, "Successfully loaded discovered identity" @@ -308,18 +316,21 @@ impl AppContext { } /// Persist one discovery result unless automatic discovery must leave it unloaded. + /// + /// A persisted result returns its exclusive load claim so the caller can + /// keep it through the corresponding wallet-cache update. pub(crate) fn persist_discovered_identity( &self, mut qualified_identity: crate::model::qualified_identity::QualifiedIdentity, seed_hash: crate::model::wallet::WalletSeedHash, identity_index: u32, explicit_reload: bool, - ) -> Result { + ) -> Result, TaskError> { let identity_id = qualified_identity.identity.id(); let load_guard = self.begin_identity_load(identity_id, None)?; if self.is_identity_forgotten(&identity_id)? && !explicit_reload { load_guard.loaded(); - return Ok(false); + return Ok(None); } match self.get_identity_by_id(&identity_id)? { @@ -338,12 +349,7 @@ impl AppContext { )?; } } - if explicit_reload { - self.finish_identity_load_after_persist(&identity_id, load_guard); - } else { - load_guard.loaded(); - } - Ok(true) + Ok(Some(load_guard)) } /// Build a QualifiedIdentity from a fetched Identity with wallet key derivation paths. @@ -576,7 +582,7 @@ mod tests { let stored = ctx .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, false) .expect("simulate discovery persistence"); - assert!(!stored, "discovery must skip a forgotten identity"); + assert!(stored.is_none(), "discovery must skip a forgotten identity"); assert_eq!( ctx.latest_identity_load_phase(&identity_id), Some(IdentityLoadPhase::Loaded), @@ -589,11 +595,11 @@ mod tests { "discovery must not resurrect an unloaded identity" ); - assert!( - ctx.persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, true) - .expect("simulate explicit wallet load"), - "an explicit load must restore the identity" - ); + let load_guard = ctx + .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, true) + .expect("simulate explicit wallet load") + .expect("an explicit load must restore the identity"); + ctx.finish_identity_load_after_persist(&identity_id, load_guard); assert!( !ctx.is_identity_forgotten(&identity_id) .expect("read cleared marker") @@ -601,11 +607,11 @@ mod tests { ctx.delete_local_qualified_identity(&identity_id) .expect("remove identity without recording another unload"); - assert!( - ctx.persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, false) - .expect("simulate discovery after explicit reload"), - "discovery must work normally after the explicit load clears the marker" - ); + let load_guard = ctx + .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, false) + .expect("simulate discovery after explicit reload") + .expect("discovery must work normally after explicit reload"); + load_guard.loaded(); assert!( ctx.get_identity_by_id(&identity_id) .expect("read rediscovered identity") @@ -625,11 +631,11 @@ mod tests { [], ) .expect("install marker cleanup failure trigger"); - assert!( - ctx.persist_discovered_identity(identity, wallet_seed_hash, 4, true) - .expect("persist despite marker cleanup failure"), - "durable persistence remains successful when marker cleanup fails" - ); + let load_guard = ctx + .persist_discovered_identity(identity, wallet_seed_hash, 4, true) + .expect("persist despite marker cleanup failure") + .expect("durable persistence must remain successful"); + ctx.finish_identity_load_after_persist(&identity_id, load_guard); assert_eq!( ctx.latest_identity_load_phase(&identity_id), Some(IdentityLoadPhase::Loaded) @@ -642,4 +648,74 @@ mod tests { backend.shutdown().await; } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn unload_during_discovery_persist_does_not_resurrect_wallet_cache() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed([0x64; 64], Network::Testnet, None, None).expect("build wallet"), + )); + let wallet_seed_hash = wallet.read().expect("read wallet").seed_hash(); + ctx.wallets() + .write() + .expect("write wallets") + .insert(wallet_seed_hash, Arc::clone(&wallet)); + let identity_id = Identifier::from([0x65; 32]); + let identity = wallet_derived_identity(identity_id, &wallet, 5); + + let load_guard = ctx + .persist_discovered_identity(identity.clone(), wallet_seed_hash, 5, false) + .expect("persist discovery") + .expect("discovery must persist a new identity"); + + let unload_ctx = Arc::clone(&ctx); + let (attempted_tx, attempted_rx) = tokio::sync::oneshot::channel(); + let unload_task = tokio::spawn(async move { + let mut attempted_tx = Some(attempted_tx); + loop { + match unload_ctx.unload_identity(identity_id) { + Err(TaskError::IdentityLoadInProgress { .. }) => { + if let Some(attempted_tx) = attempted_tx.take() { + let _ = attempted_tx.send(()); + } + tokio::task::yield_now().await; + } + result => return result, + } + } + }); + attempted_rx + .await + .expect("unload must overlap the held discovery claim"); + + wallet + .write() + .expect("write wallet") + .identities + .insert(5, identity.identity); + load_guard.loaded(); + unload_task + .await + .expect("join unload") + .expect("unload after discovery cache insertion"); + + assert!( + wallet + .read() + .expect("read wallet") + .identities + .values() + .all(|identity| identity.id() != identity_id), + "an unload that completes during discovery must not be overwritten by a late cache insert" + ); + + backend.shutdown().await; + } } diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 1556ade3c..d3d798058 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -564,12 +564,9 @@ impl AppContext { if let Some(password) = encryption_password { self.protect_identity_keys(qualified_identity.identity.id(), password, None)?; } - self.clear_forgotten_identity_after_explicit_load(&identity_id)?; - - // Past the last fallible step: the node is stored with its keys as - // requested. Anything that failed before this — including a key seal that - // left the insert behind — reported `Failed` when the guard dropped. - load_guard.loaded(); + // The identity is durably stored with its keys as requested. Clearing + // the discovery marker is best-effort and must not mask that success. + self.finish_identity_load_after_persist(&identity_id, load_guard); Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) } @@ -1572,4 +1569,60 @@ mod tests { ctx.wallet_backend().expect("backend").shutdown().await; } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn persisted_load_with_marker_cleanup_failure_still_reports_success() { + use crate::context::identity_load_registry::IdentityLoadPhase; + use crate::context::test_support::test_app_context; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let (mut qualified_identity, _) = masternode_shaped_qi(); + qualified_identity.identity_type = IdentityType::User; + qualified_identity.private_keys = KeyStorage::default(); + let identity_id = qualified_identity.identity.id(); + ctx.insert_local_qualified_identity(&qualified_identity, &None) + .expect("durably persist loaded identity"); + ctx.db() + .record_forgotten_identity(Network::Testnet, &identity_id) + .expect("record forgotten marker"); + ctx.db() + .execute( + "CREATE TRIGGER fail_load_marker_cleanup + BEFORE DELETE ON forgotten_identities + BEGIN + SELECT RAISE(FAIL, 'injected load marker cleanup failure'); + END;", + [], + ) + .expect("install marker cleanup failure trigger"); + let token = ctx + .mark_identity_load_submitted(identity_id) + .expect("submit load"); + let load_guard = ctx + .begin_identity_load(identity_id, Some(token)) + .expect("claim load"); + + ctx.finish_identity_load_after_persist(&identity_id, load_guard); + + assert!( + ctx.get_local_qualified_identity(&identity_id) + .expect("read persisted identity") + .is_some(), + "the identity must remain durably persisted" + ); + assert_eq!( + ctx.identity_load_phase(&identity_id, token), + Some(IdentityLoadPhase::Loaded), + "cleanup residue must not turn a committed load into a reported failure" + ); + + backend.shutdown().await; + } } diff --git a/src/backend_task/identity/remove_identity.rs b/src/backend_task/identity/remove_identity.rs index 84d1a4644..b44ce0b37 100644 --- a/src/backend_task/identity/remove_identity.rs +++ b/src/backend_task/identity/remove_identity.rs @@ -33,6 +33,7 @@ impl AppContext { let mut removed_identity_ids = vec![identity_id]; let primary_cleanup_failed = cleanup_error.is_some(); let mut associated_cleanup_failed = false; + let mut associated_removal_failed = false; if let Some(voter_id) = associated_voter_identity_id.filter(|id| *id != identity_id) { match self.unload_local_qualified_identity(&voter_id) { Ok(()) => { @@ -45,11 +46,11 @@ impl AppContext { associated_cleanup_failed = true; } Err(error) => { - associated_cleanup_failed = true; + associated_removal_failed = true; tracing::warn!( ?error, voter_identity_id = %voter_id, - "Associated voter identity cleanup failed" + "Associated voter identity could not be removed" ); } } @@ -59,6 +60,7 @@ impl AppContext { identity_ids: removed_identity_ids, primary_cleanup_failed, associated_cleanup_failed, + associated_removal_failed, }) } } @@ -195,6 +197,7 @@ mod tests { BackendTaskSuccessResult::RemovedIdentities { primary_cleanup_failed: true, associated_cleanup_failed: false, + associated_removal_failed: false, .. } )); @@ -205,11 +208,71 @@ mod tests { BackendTaskSuccessResult::RemovedIdentities { primary_cleanup_failed: false, associated_cleanup_failed: true, + associated_removal_failed: false, .. } )); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn remove_identity_distinguishes_associated_removal_failure_from_cleanup_residue() { + use crate::app::TaskResult; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let platform_version = PlatformVersion::latest(); + let target_id = Identifier::from([0x84; 32]); + let voter_id = Identifier::from([0x85; 32]); + let target = Identity::create_basic_identity(target_id, platform_version) + .expect("create target identity"); + let voter = Identity::create_basic_identity(voter_id, platform_version) + .expect("create voter identity"); + let voter_key = IdentityPublicKey::random_key(1, Some(1), platform_version); + let target = qualified_identity( + target, + Some((voter.clone(), voter_key)), + backend.secret_access(), + ); + let voter = qualified_identity(voter, None, backend.secret_access()); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + ctx.insert_local_qualified_identity(&voter, &None) + .expect("insert voter identity"); + let voter_load_guard = ctx + .begin_identity_load(voter_id, None) + .expect("hold voter load claim"); + + let result = ctx + .remove_identity(target_id) + .expect("primary removal remains successful"); + + assert!(matches!( + result, + BackendTaskSuccessResult::RemovedIdentities { + identity_ids, + primary_cleanup_failed: false, + associated_cleanup_failed: false, + associated_removal_failed: true, + } if identity_ids == vec![target_id] + )); + assert!( + ctx.get_local_qualified_identity(&voter_id) + .expect("read voter identity") + .is_some(), + "a genuine associated removal failure must leave the voter present" + ); + + drop(voter_load_guard); + backend.shutdown().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remove_identity_reconciles_wallet_cache_and_selection() { use crate::app::TaskResult; @@ -252,9 +315,11 @@ mod tests { identity_ids, primary_cleanup_failed, associated_cleanup_failed, + associated_removal_failed, } if identity_ids == &vec![target_id] && !primary_cleanup_failed && !associated_cleanup_failed + && !associated_removal_failed )); let (target_evicted, cached_sibling) = { let wallets = ctx.wallets().read().expect("read wallets"); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index ee0edabea..7ef97a10e 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -719,6 +719,7 @@ pub enum BackendTaskSuccessResult { identity_ids: Vec, primary_cleanup_failed: bool, associated_cleanup_failed: bool, + associated_removal_failed: bool, }, RefreshedIdentity(QualifiedIdentity), LoadedIdentity(QualifiedIdentity), diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 18c0fa8a9..2a199ca59 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -44,22 +44,39 @@ use std::sync::{Arc, Mutex}; fn identity_removal_message( primary_cleanup_failed: bool, associated_cleanup_failed: bool, + associated_removal_failed: bool, ) -> (&'static str, MessageType) { - match (primary_cleanup_failed, associated_cleanup_failed) { - (false, false) => ( + match ( + primary_cleanup_failed, + associated_cleanup_failed, + associated_removal_failed, + ) { + (false, false, false) => ( "The identity was removed from this device.", MessageType::Success, ), - (true, false) => ( + (true, false, false) => ( "The identity was removed, but some local data could not be cleaned up. Load and remove it again to retry.", MessageType::Warning, ), - (false, true) => ( - "The identity was removed, but its associated voter identity could not be removed. Retry after restarting the app.", + (false, true, false) => ( + "The identity and its associated voter identity were removed, but some local voter data could not be cleaned up. Load and remove the identity again to retry the cleanup.", + MessageType::Warning, + ), + (true, true, false) => ( + "The identity and its associated voter identity were removed, but some local data could not be cleaned up. Load and remove both identities again to retry.", MessageType::Warning, ), - (true, true) => ( - "The identity was removed, but some local data could not be cleaned up and its associated voter identity could not be removed. Restart the app, then load and remove the identity again to retry.", + (false, false, true) => ( + "The identity was removed, but its associated voter identity is still on this device. Restart the app, then load and remove the identity again to retry.", + MessageType::Warning, + ), + (true, false, true) => ( + "The identity was removed, but some local data could not be cleaned up and its associated voter identity is still on this device. Restart the app, then load and remove the identity again to retry.", + MessageType::Warning, + ), + (_, true, true) => ( + "The identity was removed, but the associated voter identity may still have local data on this device. Restart the app, then load and remove the identity again to retry.", MessageType::Warning, ), } @@ -1146,17 +1163,22 @@ impl ScreenLike for IdentitiesScreen { identity_ids, primary_cleanup_failed, associated_cleanup_failed, + associated_removal_failed, } => { let mut identities = self.identities.lock_recover(); for identity_id in identity_ids { identities.shift_remove(&identity_id); } drop(identities); - let (message, message_type) = - identity_removal_message(primary_cleanup_failed, associated_cleanup_failed); + let (message, message_type) = identity_removal_message( + primary_cleanup_failed, + associated_cleanup_failed, + associated_removal_failed, + ); let banner = MessageBanner::set_global(self.app_context.egui_ctx(), message, message_type); - if primary_cleanup_failed || associated_cleanup_failed { + if primary_cleanup_failed || associated_cleanup_failed || associated_removal_failed + { banner.disable_auto_dismiss(); } } @@ -1275,23 +1297,28 @@ mod tests { #[test] fn identity_removal_messages_distinguish_cleanup_outcomes() { - let (message, message_type) = identity_removal_message(false, false); + let (message, message_type) = identity_removal_message(false, false, false); assert_eq!(message, "The identity was removed from this device."); assert_eq!(message_type, crate::ui::MessageType::Success); - let (message, message_type) = identity_removal_message(true, false); + let (message, message_type) = identity_removal_message(true, false, false); assert!(message.contains("some local data could not be cleaned up")); assert!(!message.contains("associated voter identity")); assert_eq!(message_type, crate::ui::MessageType::Warning); - let (message, message_type) = identity_removal_message(false, true); - assert!(!message.contains("some local data could not be cleaned up")); - assert!(message.contains("associated voter identity could not be removed")); + let (message, message_type) = identity_removal_message(false, true, false); + assert!(message.contains("associated voter identity were removed")); + assert!(message.contains("could not be cleaned up")); + assert_eq!(message_type, crate::ui::MessageType::Warning); + + let (message, message_type) = identity_removal_message(false, false, true); + assert!(message.contains("associated voter identity is still on this device")); + assert!(!message.contains("local data could not be cleaned up")); assert_eq!(message_type, crate::ui::MessageType::Warning); - let (message, message_type) = identity_removal_message(true, true); + let (message, message_type) = identity_removal_message(true, false, true); assert!(message.contains("some local data could not be cleaned up")); - assert!(message.contains("associated voter identity could not be removed")); + assert!(message.contains("associated voter identity is still on this device")); assert_eq!(message_type, crate::ui::MessageType::Warning); } diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 59bdbe27c..8ffa7d718 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -595,6 +595,9 @@ impl ScreenLike for IdentityHubScreen { } fn display_task_error(&mut self, error: &TaskError) -> bool { + if let TaskError::IdentityUnloadCleanupFailed { identity_id, .. } = error { + self.profile_cache.remove_identity(identity_id); + } if self.handle_contact_request_error(error) { return matches!( contact_info_read_error_key(error), @@ -938,6 +941,45 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn unload_cleanup_error_invalidates_in_flight_profile_result() { + let (_temp_dir, context) = wired_test_context().await; + let identity_id = seed_user_identity(&context, 3); + let identity = context + .get_local_qualified_identity(&identity_id) + .expect("read identity") + .expect("identity exists"); + let mut screen = IdentityHubScreen::new(&context); + + assert!(screen.profile_cache.get_or_request(&identity).is_none()); + assert!(matches!( + screen.profile_cache.dispatch_pending(), + AppAction::BackendTask(_) + )); + + let error = TaskError::IdentityUnloadCleanupFailed { + identity_id, + source: Box::new(TaskError::DocumentNotFound), + }; + assert!( + !screen.display_task_error(&error), + "AppState must still render the cleanup warning" + ); + assert!( + screen + .profile_cache + .record_result(&BackendTaskSuccessResult::DashPayProfile(Some(( + "Stale name".to_string(), + "Stale bio".to_string(), + "https://example.invalid/stale.png".to_string(), + )))) + ); + assert!( + screen.profile_cache.get_or_request(&identity).is_none(), + "a profile request started before the committed unload must be discarded" + ); + } + #[test] fn a_result_for_the_selected_identity_applies() { assert!(applies_to_selected_identity(Some(id(1)), &id(1))); From ec94534be401208e7962e94b0e4100ea046588ce Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:43:17 +0000 Subject: [PATCH 16/46] test: QA regression coverage for PR #925 masternode identity lifecycle Adds executable proof for adversarial review claims against fix/issue-889-masternode-identity-lifecycle (PR #925): - is_bare_placeholder ignores status, so a RejectIfExists load silently discards a stored FailedCreation marker and reports it Active. - keep_first_unload_cleanup_error drops every cleanup failure after the first with no trace (not logged, not aggregated, not recoverable). - delete_local_qualified_identity_inner's load-registry claim makes clear_network_database's per-identity wipe fail for any identity a concurrent load (e.g. background discovery) is holding. - clear_network_database never clears the v39 forgotten_identities table, so a marker recorded before a full wipe survives it, silently blocking rediscovery of that identity after reimporting the same seed. Co-Authored-By: Claude Opus 5 --- src/backend_task/identity/load_identity.rs | 100 +++++++++++++ src/context/identity_db.rs | 49 ++++++ src/context/wallet_lifecycle/tests.rs | 165 +++++++++++++++++++++ 3 files changed, 314 insertions(+) diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index d3d798058..efae817c0 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -1103,6 +1103,106 @@ mod tests { ); } + /// QA (issue #889 review): `is_bare_placeholder` inspects only keys, + /// alias and associations — never `status`. A `FailedCreation` record + /// with no local keys is exactly as "bare" to it as a genuine empty + /// placeholder, so the `RejectIfExists` duplicate guard treats a marker + /// the registration flow relies on to remember a failed attempt + /// (`register_identity.rs`) the same as nothing-stored-at-all. + #[test] + fn is_bare_placeholder_ignores_status() { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::random(), pv).expect("basic identity"); + let failed_creation_marker = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::FailedCreation, + network: Network::Testnet, + }; + assert!( + is_bare_placeholder(&failed_creation_marker), + "BUG: a FailedCreation marker with no local keys is indistinguishable from a \ + genuine empty placeholder to is_bare_placeholder, so RejectIfExists lets a fresh \ + load take it over instead of treating the prior failed attempt as a duplicate", + ); + } + + /// QA (issue #889 review): reproduces the exact merge `load_identity` runs + /// for a `RejectIfExists` load over a bare existing record (line + /// 525-531) — build the fresh record with the hardcoded + /// `status: IdentityStatus::Active` `load_identity` always uses, then run + /// it through the real `merge_existing_keys_into`. `merge_existing_keys_into` + /// carries over keys/alias/associations but never touches `status`, so the + /// stored `FailedCreation` marker is silently discarded — a re-load of an + /// identity DET remembers as having failed creation is reported as an + /// ordinary, healthy `Active` identity with no trace the prior attempt failed. + #[test] + fn reject_if_exists_bare_takeover_silently_discards_failed_creation_status() { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::random(), pv).expect("basic identity"); + let existing_failed_creation = QualifiedIdentity { + identity: identity.clone(), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::FailedCreation, + network: Network::Testnet, + }; + assert!( + is_bare_placeholder(&existing_failed_creation), + "precondition: the FailedCreation marker must be bare enough to bypass RejectIfExists" + ); + + // The freshly-built record `load_identity` assembles before the merge + // (line 499-520) always hardcodes `status: IdentityStatus::Active`. + let mut freshly_built = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + + merge_existing_keys_into(&mut freshly_built, existing_failed_creation); + + assert_eq!( + freshly_built.status, + IdentityStatus::Active, + "BUG: merge_existing_keys_into does not carry over a non-Active stored status — \ + the FailedCreation marker is silently overwritten with Active, losing the record \ + that this identity's creation had previously failed", + ); + } + /// §10.9 / TC-EDGE-07 — a fresh load (`RejectIfExists`) of a /// ProTxHash already stored is rejected with [`TaskError::DuplicateProTxHash`] /// BEFORE any network fetch, and the already-stored node is left untouched. diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index cb2364f81..bdc5b9a81 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1382,6 +1382,55 @@ mod tests { index_add_identity(kv, id).unwrap(); } + /// QA (issue #889 review): `keep_first_unload_cleanup_error` keeps only + /// the first cleanup failure — every call after `cleanup_error` is + /// already `Some(_)` is a no-op, including on its `Err` branch. A second, + /// unrelated failure (here `InternalSendError`, standing in for e.g. a + /// vault-key-clear fault) is discarded with no trace: not merged, not + /// logged by this function, not recoverable from `cleanup_error` by any + /// caller. `delete_local_qualified_identity_inner` calls this six times + /// in sequence across independent cleanup steps with no logging of its + /// own at any call site, so a second real failure during identity + /// removal is invisible end to end — the returned + /// `IdentityUnloadCleanupFailed` names only the first failure's source. + #[test] + fn keep_first_unload_cleanup_error_silently_drops_every_later_failure() { + let identity_id = Identifier::from([0x01; 32]); + let mut cleanup_error = None; + + keep_first_unload_cleanup_error( + &mut cleanup_error, + identity_id, + Err(TaskError::IdentityNotFound), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + identity_id, + Err(TaskError::InternalSendError), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + identity_id, + Err(TaskError::InternalSendError), + ); + + match cleanup_error.expect("a cleanup error must be recorded") { + TaskError::IdentityUnloadCleanupFailed { + identity_id: id, + source, + } => { + assert_eq!(id, identity_id); + assert!( + matches!(*source, TaskError::IdentityNotFound), + "BUG: only the first failure's source is ever recoverable — the second \ + and third failures (InternalSendError) leave no trace anywhere in the \ + returned error, got {source:?}" + ); + } + other => panic!("expected IdentityUnloadCleanupFailed, got {other:?}"), + } + } + // --------------------------------------------------------------- // SEC: the redacting Debug must never print the private-key blob. // --------------------------------------------------------------- diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 2894989eb..12dc2fa54 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -2126,6 +2126,122 @@ async fn clear_network_database_wipes_local_identity_private_keys() { .await; } +/// QA (issue #889 review): `delete_local_qualified_identity_inner` now opens +/// with `begin_identity_load` (identity_db.rs:985), so the per-identity wipe +/// loop in `clear_network_database` collides with any other outstanding claim +/// on that identity — e.g. a `Background` discovery pass mid-scan. The claim +/// is held directly here to simulate that collision deterministically instead +/// of racing a real discovery task. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_network_database_reports_incomplete_when_a_load_claim_is_outstanding() { + use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, + }; + use crate::wallet_backend::IdentityKeyView; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + use std::collections::BTreeMap; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let pv = PlatformVersion::latest(); + let key = IdentityPublicKey::random_key(1, Some(1), pv); + let key_id = key.id(); + let mut private_keys = KeyStorage::default(); + private_keys.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::Clear([0x5Bu8; 32]), + ), + ); + let identity_id = Identifier::from([0x34u8; 32]); + let identity = Identity::create_basic_identity(identity_id, pv).expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + ctx.insert_local_qualified_identity(&qi, &None) + .expect("persist local identity"); + + let store = ctx.secret_store(); + let view = IdentityKeyView::new(&store, identity_id.to_buffer()); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id) + .expect("vault read before clear") + .is_some(), + "precondition: the identity private key is in the vault before clear" + ); + + // Simulate a concurrent Background discovery pass holding this + // identity's exclusive load claim for the whole span of the wipe. + let discovery_claim = ctx + .begin_identity_load(identity_id, None) + .expect("simulate an outstanding discovery claim on this identity"); + + let result = ctx.clear_network_database().await; + + match result { + Err(TaskError::WalletDataClearIncomplete { + failed, + first_error, + }) => { + assert!(failed >= 1, "the claim collision must count as a failure"); + assert!( + matches!(*first_error, TaskError::IdentityLoadInProgress { identity_id: id } if id == identity_id), + "the collision must surface as IdentityLoadInProgress, got {first_error:?}" + ); + } + other => panic!("an outstanding load claim must make the clear incomplete, got {other:?}"), + } + + // The colliding identity's own data must survive untouched: the wipe for + // THIS identity never ran. + assert_eq!( + ctx.local_identity_ids().expect("list ids after clear"), + vec![identity_id], + "the identity whose claim collided must remain locally stored" + ); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id) + .expect("vault read after clear") + .is_some(), + "the colliding identity's private key must survive the incomplete clear" + ); + + // The in-memory wallet maps are still torn down unconditionally, so the + // user sees no wallets even though on-disk state is incomplete. + assert!( + ctx.wallets().read().expect("read wallets").is_empty(), + "in-memory wallets must still be cleared despite the incomplete wipe" + ); + + drop(discovery_claim); + ctx.wallet_backend() + .expect("backend wired") + .shutdown() + .await; +} + /// A masternode removal must report an incomplete clear when its voting, /// owner, or payout key cannot be deleted from the vault. #[cfg(unix)] @@ -2256,6 +2372,55 @@ async fn clear_network_database_reports_incomplete_when_shielded_clear_fails() { } } +/// QA (issue #889 review): the v39 `forgotten_identities` table +/// (`database/forgotten_identities.rs`) lives in DET's own `data.db`, not in +/// `platform-wallet.sqlite`. `clear_network_database`'s F60 "delete all local +/// data" sweep never touches `self.db` at all — only the wallet backend's +/// vault/KV/wallet state — so a forgotten-identity marker recorded before a +/// full wipe survives it untouched. A user who unloads an identity, then +/// later runs "Clear all wallet data" expecting a genuinely fresh start, and +/// re-imports the same seed finds that identity silently un-rediscoverable — +/// automatic and wallet-unlock discovery both skip anything marked forgotten, +/// with no UI surface that shows the marker still exists. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_network_database_leaves_forgotten_identity_markers_in_place() { + use dash_sdk::platform::Identifier; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let identity_id = Identifier::from([0x35u8; 32]); + ctx.db() + .record_forgotten_identity(Network::Testnet, &identity_id) + .expect("record forgotten marker before the wipe"); + assert!( + ctx.is_identity_forgotten(&identity_id) + .expect("read marker before wipe"), + "precondition: the identity is marked forgotten before the wipe" + ); + + ctx.clear_network_database() + .await + .expect("clear_network_database should succeed with nothing else to wipe"); + + assert!( + ctx.is_identity_forgotten(&identity_id) + .expect("read marker after wipe"), + "BUG: 'Clear all wallet data' is expected to clear the forgotten_identities table, \ + but this assertion documents that the marker recorded before the wipe currently \ + survives it untouched, silently blocking rediscovery of that identity after a full \ + wipe and reimport of the same seed. Flipping this assertion is the signal that the \ + gap has been closed.", + ); + + ctx.wallet_backend() + .expect("backend wired") + .shutdown() + .await; +} + /// Clear-all must fail before changing any state when the wallet backend is /// unavailable, because persisted secrets from an earlier run may still exist. #[tokio::test] From a6a4a9db5b90de83fb50b689ddca3f0083e8f3dc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:49:35 +0000 Subject: [PATCH 17/46] test: recover QA regression coverage lost in an uncontrolled reset An unsupervised duplicate reviewer subagent (spawned outside coordinator control during PR #925's grumpy-review pass) ran `git reset` on this shared branch, discarding commit a064d3640 ("test: QA regression coverage for issue #889 identity-lifecycle edge cases") before committing its own work as ec94534be. a064d3640's content was never lost (still reachable via reflog / tag safety-snapshot-a064d3640) but was absent from the branch tip. This commit cherry-picks it back (clean auto-merge against ec94534be, no conflicts, both diffs are purely additive test code touching different regions of the same files). Restored coverage: - context/identity_db.rs: deletion_fault_after_index_removal_must_not_ permanently_orphan_vault_key -- intentionally FAILING, proves vault key material can permanently survive a faulted unload, unreachable even by the "delete all local data" sweep. Independently corroborates this session's SEC-001 (security-engineer-smythe) via a different method (executable proof vs. static reasoning). - backend_task/identity/unload_identity.rs: second_concurrent_unload_of_the_same_identity_is_rejected_without_ side_effects -- confirmed correct, no bug. - backend_task/identity/remove_identity.rs: remove_identity_handles_absent_and_self_referential_voter -- confirmed correct, no bug. - ui/identities/identities_screen.rs: extends identity_removal_messages_distinguish_cleanup_outcomes to the 2 previously-untested match arms. Original work by qa-engineer-marvin (Claude Sonnet, this session). Recovery performed by the coordinator after detecting the discarded commit while reviewing an unrelated teammate report. Co-Authored-By: Claude Sonnet 5 --- src/backend_task/identity/remove_identity.rs | 89 ++++++++++++ src/backend_task/identity/unload_identity.rs | 134 +++++++++++++++++++ src/context/identity_db.rs | 94 +++++++++++++ src/ui/identities/identities_screen.rs | 17 +++ 4 files changed, 334 insertions(+) diff --git a/src/backend_task/identity/remove_identity.rs b/src/backend_task/identity/remove_identity.rs index b44ce0b37..b2fedc6ab 100644 --- a/src/backend_task/identity/remove_identity.rs +++ b/src/backend_task/identity/remove_identity.rs @@ -357,4 +357,93 @@ mod tests { ); backend.shutdown().await; } + + /// QA (issue #889 review): the two `associated_voter_identity_id` shapes + /// the associated-voter branch has to tell apart — absent, and + /// self-referential (the voter identity is the identity being removed). + /// The `.filter(|id| *id != identity_id)` guard exists precisely to skip + /// the second identity_id==voter_id case; unlike the ordinary + /// distinct-voter path exercised elsewhere in this file, no test drove a + /// self-referential voter through the real entry point before this one. + /// A regression here would either double-process the same identity or + /// wrongly flag `associated_removal_failed`/`associated_cleanup_failed` + /// for an identity that was, in fact, fully removed by the primary step. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn remove_identity_handles_absent_and_self_referential_voter() { + use crate::app::TaskResult; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let platform_version = PlatformVersion::latest(); + + // No associated voter at all. + let no_voter_id = Identifier::from([0x86; 32]); + let no_voter_identity = Identity::create_basic_identity(no_voter_id, platform_version) + .expect("create no-voter identity"); + let no_voter = qualified_identity(no_voter_identity, None, backend.secret_access()); + ctx.insert_local_qualified_identity(&no_voter, &None) + .expect("insert no-voter identity"); + + let result = ctx + .remove_identity(no_voter_id) + .expect("remove an identity with no associated voter"); + assert!( + matches!( + &result, + BackendTaskSuccessResult::RemovedIdentities { + identity_ids, + primary_cleanup_failed: false, + associated_cleanup_failed: false, + associated_removal_failed: false, + } if identity_ids == &vec![no_voter_id] + ), + "an identity with no associated voter must report exactly itself and no \ + associated-voter failure flags, got {result:?}" + ); + + // Self-referential voter: associated_voter_identity_id == identity_id. + let self_voter_id = Identifier::from([0x87; 32]); + let self_voter_identity = Identity::create_basic_identity(self_voter_id, platform_version) + .expect("create self-referential identity"); + let self_voter_key = IdentityPublicKey::random_key(1, Some(1), platform_version); + let self_voter = qualified_identity( + self_voter_identity.clone(), + Some((self_voter_identity, self_voter_key)), + backend.secret_access(), + ); + ctx.insert_local_qualified_identity(&self_voter, &None) + .expect("insert self-referential identity"); + + let result = ctx + .remove_identity(self_voter_id) + .expect("remove a self-referentially-voting identity"); + assert!( + matches!( + &result, + BackendTaskSuccessResult::RemovedIdentities { + identity_ids, + primary_cleanup_failed: false, + associated_cleanup_failed: false, + associated_removal_failed: false, + } if identity_ids == &vec![self_voter_id] + ), + "a self-referential voter must not be double-processed or reported as an \ + associated-removal failure, got {result:?}" + ); + assert!( + ctx.get_local_qualified_identity(&self_voter_id) + .expect("read removed self-referential identity") + .is_none(), + "the self-referential identity must actually be gone, not just reported as such" + ); + + backend.shutdown().await; + } } diff --git a/src/backend_task/identity/unload_identity.rs b/src/backend_task/identity/unload_identity.rs index e7485cb88..22a6219cf 100644 --- a/src/backend_task/identity/unload_identity.rs +++ b/src/backend_task/identity/unload_identity.rs @@ -287,4 +287,138 @@ mod tests { .expect("remove owner-overlay delete trigger"); backend.shutdown().await; } + + /// QA (issue #889 review): a second unload of the identity a first unload + /// already claims must be rejected outright through the real + /// `unload_identity()` task handler (not just the lower-level + /// `delete_local_qualified_identity_inner`), and — because it never got + /// past `begin_identity_load` — must leave in-memory state completely + /// untouched: `error.identity_was_removed()` is false for + /// `IdentityLoadInProgress`, so `unload_identity()` returns before ever + /// calling `reconcile_unloaded_identity_memory`. A rejected concurrent + /// unload evicting the wallet cache or clearing the selection anyway + /// would be a real bug: it would desync the UI from storage, which still + /// holds the identity untouched. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn second_concurrent_unload_of_the_same_identity_is_rejected_without_side_effects() { + use crate::app::TaskResult; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let platform_version = PlatformVersion::latest(); + let target_id = Identifier::from([0x91; 32]); + let target = Identity::create_basic_identity(target_id, platform_version) + .expect("create target identity"); + let mut wallet = Wallet::new_from_seed([0x93; 64], Network::Testnet, None, None) + .expect("build test wallet"); + wallet.identities.insert(3, target.clone()); + let wallet_seed_hash = wallet.seed_hash(); + let qualified_identity = QualifiedIdentity { + identity: target, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: Vec::new(), + associated_wallets: BTreeMap::from([( + wallet_seed_hash, + Arc::new(RwLock::new(wallet.clone())), + )]), + secret_access: Some(backend.secret_access()), + wallet_index: Some(3), + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + ctx.insert_local_qualified_identity(&qualified_identity, &Some((wallet_seed_hash, 3))) + .expect("insert target identity"); + ctx.wallets() + .write() + .expect("write wallets") + .insert(wallet_seed_hash, Arc::new(RwLock::new(wallet))); + ctx.set_selected_identity(Some(target_id)); + ctx.set_pending_identity_selection(target_id); + + // Simulate a first unload already in flight by holding its exclusive + // claim directly, the same claim `unload_local_qualified_identity` + // takes internally. + let first_unload_guard = ctx + .begin_identity_load(target_id, None) + .expect("claim the identity for the first, in-flight unload"); + + let second_unload_error = ctx + .unload_identity(target_id) + .expect_err("a second unload of the same identity must be rejected, not raced"); + assert!( + matches!( + second_unload_error, + TaskError::IdentityLoadInProgress { identity_id } if identity_id == target_id + ), + "the rejection must be IdentityLoadInProgress, got {second_unload_error:?}" + ); + + // Nothing the rejected call touched: storage, wallet cache, and + // selection must all still reflect the identity as loaded. + assert!( + ctx.get_local_qualified_identity(&target_id) + .expect("read identity") + .is_some(), + "a rejected concurrent unload must not remove the identity from storage" + ); + let target_is_cached = { + let wallets = ctx.wallets().read().expect("read wallets"); + let wallet = wallets + .get(&wallet_seed_hash) + .expect("wallet remains") + .read() + .expect("read wallet"); + wallet + .identities + .values() + .any(|identity| identity.id() == target_id) + }; + assert!( + target_is_cached, + "a rejected concurrent unload must not evict the wallet cache" + ); + assert_eq!( + ctx.selected_identity_id(), + Some(target_id), + "a rejected concurrent unload must not clear the selection" + ); + assert_eq!( + ctx.take_pending_identity_selection(), + Some(target_id), + "a rejected concurrent unload must not clear the pending selection" + ); + ctx.set_pending_identity_selection(target_id); + + // Once the first unload's claim is released, a genuine second attempt + // must succeed and actually perform the unload this time. + drop(first_unload_guard); + let result = ctx + .unload_identity(target_id) + .expect("unload succeeds once the in-flight claim is released"); + assert!(matches!( + result, + BackendTaskSuccessResult::UnloadedIdentity(identity_id) if identity_id == target_id + )); + assert!( + ctx.get_local_qualified_identity(&target_id) + .expect("read identity") + .is_none(), + "the retried unload must actually remove the identity" + ); + + backend.shutdown().await; + } } diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index bdc5b9a81..31322edee 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -2420,6 +2420,100 @@ mod tests { backend.shutdown().await; } + /// QA (issue #889 review): the same fault as + /// `deletion_fault_after_index_removal_leaves_no_visible_zombie` — index + /// removed, `clear_identity_vault_keys` then fails because the corrupted + /// blob can no longer be decoded to learn which vault labels to erase — + /// but followed one step further, to the vault itself and to the + /// "delete all local data" sweep. + /// + /// Requirement (CHANGELOG "Unload an identity from this device": "It + /// removes every piece of locally stored data for that identity (keys, + /// ...)"; the F60 comment in `clear_network_database`: "a full wipe must + /// remove [Tier-1 keyless identity keys] as well"): a private key must + /// never survive both a failed identity deletion AND a subsequent + /// "delete all local data" sweep. `purge_identity_scope` currently runs + /// unconditionally after a `clear_identity_vault_keys` failure and + /// deletes the identity blob — per `IdentityKeyView`'s own doc comment + /// "the only on-disk marker that the key exists" — permanently + /// orphaning the vault entry: `clear_network_database`'s per-identity + /// sweep enumerates exclusively via `local_identity_ids()`, which no + /// longer lists this identity once its index entry (and blob) are gone. + /// EXPECTED (currently FAILS): the vault key must still be reachable — + /// i.e. cleared, or at minimum still discoverable/retryable — after the + /// full-wipe sweep. This test currently fails, proving the leak. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn deletion_fault_after_index_removal_must_not_permanently_orphan_vault_key() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let target_id = Identifier::from([0xA1; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0xA2; 32], [0xA3; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + + let target_buf = target_id.to_buffer(); + let target_vault = IdentityKeyView::new(backend.secret_store(), target_buf); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read target key before deletion") + .is_some(), + "the vault key must exist before the faulted deletion" + ); + + // Corrupt the stored blob in place, exactly as the sibling test does, + // so `clear_identity_vault_keys` fails to decode it and never learns + // which vault labels belong to this identity. + let id_buf = target_id.to_buffer(); + let kv = ctx.det_kv().expect("det kv"); + kv.put(DetScope::Identity(&id_buf), IDENTITY_KEY, &stored("User")) + .expect("corrupt the stored blob"); + + assert!( + ctx.delete_local_qualified_identity(&target_id).is_err(), + "the corrupted blob must surface as an error" + ); + assert!( + !ctx.local_identity_ids() + .expect("read index") + .contains(&target_id), + "precondition: the identity is already hidden from the index" + ); + + // Simulate clear_network_database's (F60 "delete all local data") own + // per-identity sweep, which enumerates strictly through + // `local_identity_ids()` (see context/wallet_lifecycle/spv.rs). + for owner in ctx.local_identity_ids().expect("read index for sweep") { + let _ = ctx.delete_local_qualified_identity(&owner); + } + + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read target key after full-wipe sweep") + .is_none(), + "the vault key must not permanently survive a faulted deletion followed by the \ + 'delete all local data' sweep — clear_network_database documents that a full \ + wipe removes every identity's private-key material, but purge_identity_scope \ + deleted the blob (the only on-disk record of which vault labels belonged to \ + this identity) before the vault-key clear it depends on ever ran, and the \ + sweep can no longer find this identity at all once its index entry is gone" + ); + + backend.shutdown().await; + } + /// Load-path migration — `migrate_keystore_to_vault` content-detects Clear/AlwaysClear, /// stores them in the vault FIRST, then rewrites the blob to InVault. /// Asserts: vault-first (the raw bytes are present), the wallet-derived key diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 2a199ca59..8c05563e3 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -1320,6 +1320,23 @@ mod tests { assert!(message.contains("some local data could not be cleaned up")); assert!(message.contains("associated voter identity is still on this device")); assert_eq!(message_type, crate::ui::MessageType::Warning); + + // QA (issue #889 review): the two combos the original test left + // unexercised — both cleanup flags true (primary AND associated + // cleanup left residue, but nothing was left un-removed), and the + // `(_, true, true)` catch-all arm (defensive: `remove_identity` + // never sets `associated_cleanup_failed` and + // `associated_removal_failed` together, but the match is exhaustive + // over all 8 bool combinations regardless). + let (message, message_type) = identity_removal_message(true, true, false); + assert!(message.contains("associated voter identity were removed")); + assert!(message.contains("some local data could not be cleaned up")); + assert!(message.contains("both identities")); + assert_eq!(message_type, crate::ui::MessageType::Warning); + + let (message, message_type) = identity_removal_message(false, true, true); + assert!(message.contains("may still have local data")); + assert_eq!(message_type, crate::ui::MessageType::Warning); } /// The Identities list Name cell shows the identity's name and, when a DPNS From 5bc9cfce2244a7b1d3668f20de5b1513cb828e05 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:30:01 +0000 Subject: [PATCH 18/46] fix: address grumpy-review batch 2 findings (SEC/PROJ/CODE/DOC, PR #925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies 11 of the 30 grumpy-review findings via Codex Sol, independently re-verified (tests, fmt, clippy all clean in this worktree): - SEC-002/SEC-004/SEC-006: identity_db.rs unload/removal hardening — forgotten markers now unconditional on unload; unwired wallet cleanup returns an error instead of false success; destructive overlays removed only after the identity-removal commit point; rollback failures no longer replace the original failure. - SEC-008: FailedCreation records are no longer treated as bare placeholders (load_identity.rs), closing a takeover path into merge_existing_keys_into. - PROJ-001 + CALL-001: masternode detail's remove_node now dispatches the shared async IdentityTask::RemoveIdentity backend task instead of a synchronous hand-rolled DB/vault deletion on the UI thread; list_screen distinguishes matching-node success (closes detail view) from failure (stays open) and unrelated results (reopens for fresh data). - PROJ-005 + CALL-001: DashPay profile_screen/dashpay_screen results are now generation/identity guarded, closing the late-result gap the Phase-3 fix only closed for hub_screen. - CODE-001: documented mutually-exclusive remove_identity outcome flags, a construction-site debug_assert!, and a reachability test — chosen over a new enum as the less invasive fix. - CODE-002: additional cleanup failures are now logged (first failure stays primary via keep_first_unload_cleanup_error). - DOC-002/DOC-003: CHANGELOG and identity_load_registry docs corrected to match actual behavior. Not included in this batch: SEC-001/SEC-003/PROJ-002/PROJ-006/SEC-007 (queued for a user architecture decision) and PROJ-003 (small follow-up fix, applied separately). Job: task-ms0mc7zb-l6ra6c (Codex Sol, --effort high) Verification: cargo-cached.sh, identity_db/discover_identities/ load_identity/remove_identity/detail_screen/list_screen/profile_screen/ dashpay_screen/identity_load_registry test scopes all green except the one intentionally-red SEC-001 proof (deletion_fault_after_index_removal_must_not_permanently_orphan_vault_key); cargo fmt --all -- --check and cargo clippy --all-features --bin dash-evo-tool -- -D warnings both clean. --- CHANGELOG.md | 15 +- .../identity/discover_identities.rs | 4 +- src/backend_task/identity/load_identity.rs | 59 +-- src/backend_task/identity/remove_identity.rs | 17 + src/backend_task/mod.rs | 2 + src/context/identity_db.rs | 365 ++++++++++++++---- src/context/identity_load_registry.rs | 16 +- src/ui/dashpay/dashpay_screen.rs | 3 +- src/ui/dashpay/profile_screen.rs | 137 ++++++- src/ui/masternodes/detail_screen.rs | 77 ++-- src/ui/masternodes/list_screen.rs | 52 ++- 11 files changed, 529 insertions(+), 218 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4189feaf..b909db603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **Unload an identity from this device**: Identity Hub → Settings now has a - working "Unload this identity from this device" action. It removes every - piece of locally stored data for that identity (keys, cached profile, - DashPay data) while leaving your other identities untouched. The identity - itself is unaffected on the network. Wallet-derived private keys can be - restored from the wallet. To load the identity again, you need recovery - information for any keys stored only on this device. + working "Unload this identity from this device" action. It removes the + identity from the app on this device and clears the local keys, profile + information, and DashPay details that the app can identify for it, while + leaving your other identities untouched. If cleanup cannot finish, the app + reports that clearly so you can retry. The identity itself is unaffected on + the network. Wallet-derived private keys can be restored from the wallet. To + load the identity again, you need recovery information for any keys stored + only on this device. Unloaded identities now stay unloaded after automatic + wallet discovery, and delayed profile results are ignored after an unload. - **Automatic Platform node refresh during upgrades**: migrating a pre-1.0 installation now triggers a best-effort Mainnet or Testnet node refresh. diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index da98ec1ca..3c643e57d 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -569,8 +569,8 @@ mod tests { .insert(wallet_seed_hash, Arc::clone(&wallet)); let identity_id = Identifier::from([0x62; 32]); let identity = wallet_derived_identity(identity_id, &wallet, 4); - ctx.insert_local_qualified_identity(&identity, &Some((wallet_seed_hash, 4))) - .expect("insert wallet-derived identity"); + ctx.insert_local_qualified_identity(&identity, &None) + .expect("insert identity without a stored wallet association"); ctx.unload_identity(identity_id) .expect("unload wallet-derived identity"); diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index efae817c0..81539a0c9 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -46,6 +46,7 @@ fn is_bare_placeholder(qualified_identity: &QualifiedIdentity) -> bool { && qualified_identity.associated_voter_identity.is_none() && qualified_identity.associated_operator_identity.is_none() && qualified_identity.associated_owner_key_id.is_none() + && qualified_identity.status == IdentityStatus::Active } /// Merge an already-stored identity's keys and associations into a freshly @@ -1103,14 +1104,8 @@ mod tests { ); } - /// QA (issue #889 review): `is_bare_placeholder` inspects only keys, - /// alias and associations — never `status`. A `FailedCreation` record - /// with no local keys is exactly as "bare" to it as a genuine empty - /// placeholder, so the `RejectIfExists` duplicate guard treats a marker - /// the registration flow relies on to remember a failed attempt - /// (`register_identity.rs`) the same as nothing-stored-at-all. #[test] - fn is_bare_placeholder_ignores_status() { + fn failed_creation_status_is_not_a_bare_placeholder() { let pv = PlatformVersion::latest(); let identity = Identity::create_basic_identity(Identifier::random(), pv).expect("basic identity"); @@ -1131,24 +1126,13 @@ mod tests { network: Network::Testnet, }; assert!( - is_bare_placeholder(&failed_creation_marker), - "BUG: a FailedCreation marker with no local keys is indistinguishable from a \ - genuine empty placeholder to is_bare_placeholder, so RejectIfExists lets a fresh \ - load take it over instead of treating the prior failed attempt as a duplicate", + !is_bare_placeholder(&failed_creation_marker), + "a failed-creation marker must make RejectIfExists report a duplicate", ); } - /// QA (issue #889 review): reproduces the exact merge `load_identity` runs - /// for a `RejectIfExists` load over a bare existing record (line - /// 525-531) — build the fresh record with the hardcoded - /// `status: IdentityStatus::Active` `load_identity` always uses, then run - /// it through the real `merge_existing_keys_into`. `merge_existing_keys_into` - /// carries over keys/alias/associations but never touches `status`, so the - /// stored `FailedCreation` marker is silently discarded — a re-load of an - /// identity DET remembers as having failed creation is reported as an - /// ordinary, healthy `Active` identity with no trace the prior attempt failed. #[test] - fn reject_if_exists_bare_takeover_silently_discards_failed_creation_status() { + fn reject_if_exists_does_not_take_over_failed_creation_status() { let pv = PlatformVersion::latest(); let identity = Identity::create_basic_identity(Identifier::random(), pv).expect("basic identity"); @@ -1169,37 +1153,8 @@ mod tests { network: Network::Testnet, }; assert!( - is_bare_placeholder(&existing_failed_creation), - "precondition: the FailedCreation marker must be bare enough to bypass RejectIfExists" - ); - - // The freshly-built record `load_identity` assembles before the merge - // (line 499-520) always hardcodes `status: IdentityStatus::Active`. - let mut freshly_built = QualifiedIdentity { - identity, - associated_voter_identity: None, - associated_operator_identity: None, - associated_owner_key_id: None, - identity_type: IdentityType::User, - alias: None, - private_keys: KeyStorage::default(), - dpns_names: vec![], - associated_wallets: BTreeMap::new(), - secret_access: None, - wallet_index: None, - top_ups: BTreeMap::new(), - status: IdentityStatus::Active, - network: Network::Testnet, - }; - - merge_existing_keys_into(&mut freshly_built, existing_failed_creation); - - assert_eq!( - freshly_built.status, - IdentityStatus::Active, - "BUG: merge_existing_keys_into does not carry over a non-Active stored status — \ - the FailedCreation marker is silently overwritten with Active, losing the record \ - that this identity's creation had previously failed", + !is_bare_placeholder(&existing_failed_creation), + "RejectIfExists must stop before the bare-placeholder merge can replace the status", ); } diff --git a/src/backend_task/identity/remove_identity.rs b/src/backend_task/identity/remove_identity.rs index b2fedc6ab..7c8da5fd2 100644 --- a/src/backend_task/identity/remove_identity.rs +++ b/src/backend_task/identity/remove_identity.rs @@ -55,6 +55,10 @@ impl AppContext { } } } + debug_assert!( + !(associated_cleanup_failed && associated_removal_failed), + "one associated voter cannot be both removed-with-residue and retained" + ); Ok(BackendTaskSuccessResult::RemovedIdentities { identity_ids: removed_identity_ids, @@ -214,6 +218,19 @@ mod tests { )); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn remove_identity_never_combines_associated_cleanup_and_removal_failures() { + let result = removal_result_with_cleanup_failure(true).await; + assert!(matches!( + result, + BackendTaskSuccessResult::RemovedIdentities { + associated_cleanup_failed: true, + associated_removal_failed: false, + .. + } + )); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remove_identity_distinguishes_associated_removal_failure_from_cleanup_residue() { use crate::app::TaskResult; diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7ef97a10e..b1e9af980 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -718,7 +718,9 @@ pub enum BackendTaskSuccessResult { RemovedIdentities { identity_ids: Vec, primary_cleanup_failed: bool, + /// Mutually exclusive with `associated_removal_failed`. associated_cleanup_failed: bool, + /// Mutually exclusive with `associated_cleanup_failed`. associated_removal_failed: bool, }, RefreshedIdentity(QualifiedIdentity), diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 31322edee..960363f58 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -74,13 +74,19 @@ fn keep_first_unload_cleanup_error( identity_id: Identifier, result: std::result::Result<(), TaskError>, ) { - if cleanup_error.is_none() { - *cleanup_error = result - .err() - .map(|source| TaskError::IdentityUnloadCleanupFailed { + if let Err(source) = result { + if cleanup_error.is_none() { + *cleanup_error = Some(TaskError::IdentityUnloadCleanupFailed { identity_id, source: Box::new(source), }); + } else { + tracing::warn!( + identity_id = %identity_id, + error = ?source, + "Additional identity unload cleanup step failed" + ); + } } } @@ -979,7 +985,7 @@ impl AppContext { fn delete_local_qualified_identity_inner( &self, identifier: &Identifier, - remember_wallet_derived_unload: bool, + remember_unload: bool, ) -> std::result::Result<(), TaskError> { // The load registry provides the existing per-identity exclusive claim. let load_guard = self.begin_identity_load(*identifier, None)?; @@ -991,54 +997,14 @@ impl AppContext { return Err(TaskError::WalletStorageNotReady); } let kv = self.det_kv()?; + let backend = self.wallet_backend()?; let id = identifier.to_buffer(); - let should_remember_unload = remember_wallet_derived_unload - && kv - .get::(DetScope::Identity(&id), IDENTITY_KEY) - .map_err(identity_err)? - .is_some_and(|stored| { - stored.wallet_hash.is_some() && stored.wallet_index.is_some() - }); crate::backend_task::migration::finish_unwire::record_identity_deletion(self, id).map_err( |source| TaskError::IdentityDeletionMigrationRecord { source: Arc::new(source), }, )?; - let mut cleanup_error = None; - match self.wallet_backend() { - Ok(backend) => { - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - backend.dashpay_clear_owner_overlays(identifier), - ); - // Conversation/payment timestamps that are not keyed by this identity - // may be shared; full-wallet teardown is the safe reclamation boundary. - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - backend.dashpay_clear_identity_timestamps(identifier), - ); - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - backend.dashpay_clear_identity_addr_map(identifier), - ); - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - backend.identity_meta().delete(self.network, &id), - ); - } - Err(TaskError::WalletBackendNotYetWired) => { - tracing::warn!( - identity_id = %identifier, - "Identity unload left DashPay overlays, timestamps, address mappings, and identity details because the wallet backend is not wired" - ); - } - Err(error) => return Err(error), - } - if should_remember_unload { + if remember_unload { self.db .record_forgotten_identity(self.network, identifier) .map_err(|source| TaskError::ForgottenIdentityStorage { source })?; @@ -1050,13 +1016,42 @@ impl AppContext { // `purge_identity_scope`, since it reads the identity blob that // `purge_identity_scope` deletes. if let Err(error) = index_remove_identity(&kv, &id) { - if should_remember_unload { - self.db - .clear_forgotten_identity(self.network, identifier) - .map_err(|source| TaskError::ForgottenIdentityStorage { source })?; + if remember_unload + && let Err(rollback_error) = + self.db.clear_forgotten_identity(self.network, identifier) + { + tracing::warn!( + identity_id = %identifier, + original_error = ?error, + rollback_error = ?rollback_error, + "Identity unload marker rollback failed after the removal commit failed" + ); } return Err(error); } + let mut cleanup_error = None; + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_owner_overlays(identifier), + ); + // Conversation/payment timestamps that are not keyed by this identity + // may be shared; full-wallet teardown is the safe reclamation boundary. + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_identity_timestamps(identifier), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_identity_addr_map(identifier), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.identity_meta().delete(self.network, &id), + ); keep_first_unload_cleanup_error( &mut cleanup_error, *identifier, @@ -1352,7 +1347,8 @@ mod tests { use super::*; use crate::wallet_backend::kv_test_support::InMemoryKv; use DetKv; - use std::sync::Arc; + use std::io::Write; + use std::sync::{Arc, Mutex}; fn empty_kv() -> DetKv { DetKv::from_store(Arc::new(InMemoryKv::default())) @@ -1382,37 +1378,62 @@ mod tests { index_add_identity(kv, id).unwrap(); } - /// QA (issue #889 review): `keep_first_unload_cleanup_error` keeps only - /// the first cleanup failure — every call after `cleanup_error` is - /// already `Some(_)` is a no-op, including on its `Err` branch. A second, - /// unrelated failure (here `InternalSendError`, standing in for e.g. a - /// vault-key-clear fault) is discarded with no trace: not merged, not - /// logged by this function, not recoverable from `cleanup_error` by any - /// caller. `delete_local_qualified_identity_inner` calls this six times - /// in sequence across independent cleanup steps with no logging of its - /// own at any call site, so a second real failure during identity - /// removal is invisible end to end — the returned - /// `IdentityUnloadCleanupFailed` names only the first failure's source. + #[derive(Clone, Default)] + struct SharedLog(Arc>>); + + struct SharedLogWriter(Arc>>); + + impl Write for SharedLogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 + .lock() + .expect("lock captured log") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLog { + type Writer = SharedLogWriter; + + fn make_writer(&'a self) -> Self::Writer { + SharedLogWriter(Arc::clone(&self.0)) + } + } + #[test] - fn keep_first_unload_cleanup_error_silently_drops_every_later_failure() { + fn keep_first_unload_cleanup_error_logs_every_later_failure() { let identity_id = Identifier::from([0x01; 32]); let mut cleanup_error = None; - - keep_first_unload_cleanup_error( - &mut cleanup_error, - identity_id, - Err(TaskError::IdentityNotFound), - ); - keep_first_unload_cleanup_error( - &mut cleanup_error, - identity_id, - Err(TaskError::InternalSendError), - ); - keep_first_unload_cleanup_error( - &mut cleanup_error, - identity_id, - Err(TaskError::InternalSendError), - ); + let captured = SharedLog::default(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_max_level(tracing::Level::WARN) + .with_writer(captured.clone()) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + keep_first_unload_cleanup_error( + &mut cleanup_error, + identity_id, + Err(TaskError::IdentityNotFound), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + identity_id, + Err(TaskError::InternalSendError), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + identity_id, + Err(TaskError::InternalSendError), + ); + }); match cleanup_error.expect("a cleanup error must be recorded") { TaskError::IdentityUnloadCleanupFailed { @@ -1422,13 +1443,20 @@ mod tests { assert_eq!(id, identity_id); assert!( matches!(*source, TaskError::IdentityNotFound), - "BUG: only the first failure's source is ever recoverable — the second \ - and third failures (InternalSendError) leave no trace anywhere in the \ - returned error, got {source:?}" + "the first failure remains the primary source, got {source:?}" ); } other => panic!("expected IdentityUnloadCleanupFailed, got {other:?}"), } + let output = + String::from_utf8(captured.0.lock().expect("lock captured log").clone()).unwrap(); + assert_eq!( + output + .matches("Additional identity unload cleanup step failed") + .count(), + 2, + "the second and third failures must each be visible in logs: {output}" + ); } // --------------------------------------------------------------- @@ -2169,6 +2197,177 @@ mod tests { backend.shutdown().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn identity_unload_reports_failure_when_wallet_backend_is_unwired() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let target_id = Identifier::from([0xB1; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0xB2; 32], [0xB3; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + + ctx.wallet_backend.store(None); + + assert!( + matches!( + ctx.unload_local_qualified_identity(&target_id), + Err(TaskError::WalletBackendNotYetWired) + ), + "unload must not report success when its cleanup backend is unavailable" + ); + assert!( + backend + .kv() + .get::( + DetScope::Identity(&target_id.to_buffer()), + IDENTITY_KEY, + ) + .expect("read retained identity") + .is_some(), + "an unload rejected before its commit point must retain the identity" + ); + + backend.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_unload_before_commit_preserves_dashpay_overlays() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::model::dashpay::ContactPrivateInfo; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let target_id = Identifier::from([0xB4; 32]); + let contact_id = Identifier::from([0xB5; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0xB6; 32], [0xB7; 32]); + ctx.insert_local_qualified_identity(&target, &Some(([0xB8; 32], 1))) + .expect("insert target identity"); + backend + .dashpay_set_private_info( + &target_id, + &contact_id, + &ContactPrivateInfo { + nickname: "retained contact".into(), + notes: "retained note".into(), + is_hidden: false, + }, + ) + .expect("seed owner overlay"); + + let persister_path = backend.spv_storage_dir().join("platform-wallet.sqlite"); + let fault_connection = + rusqlite::Connection::open(&persister_path).expect("open persister second handle"); + fault_connection + .execute_batch( + "CREATE TRIGGER fail_identity_index_commit + BEFORE INSERT ON meta_global + WHEN NEW.key = 'det:identity_index:v1' + BEGIN + SELECT RAISE(FAIL, 'injected identity-index failure'); + END;", + ) + .expect("install identity-index trigger"); + + assert!( + matches!( + ctx.unload_local_qualified_identity(&target_id), + Err(TaskError::IdentityStorage { .. }) + ), + "the failed commit must surface its identity-storage error" + ); + assert!( + backend + .dashpay_get_private_info(&target_id, &contact_id) + .expect("read retained owner overlay") + .is_some(), + "cleanup must not destroy DashPay overlays before the unload commits" + ); + + fault_connection + .execute_batch("DROP TRIGGER fail_identity_index_commit;") + .expect("remove identity-index trigger"); + backend.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn rollback_failure_does_not_replace_original_unload_error() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let target_id = Identifier::from([0xB9; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0xBA; 32], [0xBB; 32]); + ctx.insert_local_qualified_identity(&target, &Some(([0xBC; 32], 2))) + .expect("insert target identity"); + + let persister_path = backend.spv_storage_dir().join("platform-wallet.sqlite"); + let fault_connection = + rusqlite::Connection::open(&persister_path).expect("open persister second handle"); + fault_connection + .execute_batch( + "CREATE TRIGGER fail_identity_index_commit + BEFORE INSERT ON meta_global + WHEN NEW.key = 'det:identity_index:v1' + BEGIN + SELECT RAISE(FAIL, 'injected identity-index failure'); + END;", + ) + .expect("install identity-index trigger"); + ctx.db() + .locked_conn() + .execute_batch( + "CREATE TRIGGER fail_forgotten_marker_rollback + BEFORE DELETE ON forgotten_identities + BEGIN + SELECT RAISE(FAIL, 'injected marker-rollback failure'); + END;", + ) + .expect("install marker-rollback trigger"); + + let error = ctx + .unload_local_qualified_identity(&target_id) + .expect_err("the unload commit must fail"); + assert!( + matches!(error, TaskError::IdentityStorage { .. }), + "the original commit failure must remain primary, got {error:?}" + ); + + fault_connection + .execute_batch("DROP TRIGGER fail_identity_index_commit;") + .expect("remove identity-index trigger"); + ctx.db() + .locked_conn() + .execute_batch("DROP TRIGGER fail_forgotten_marker_rollback;") + .expect("remove marker-rollback trigger"); + backend.shutdown().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn identity_unload_continues_cleanup_and_wipes_vault_after_overlay_failure() { use crate::app::TaskResult; diff --git a/src/context/identity_load_registry.rs b/src/context/identity_load_registry.rs index a1e88652d..f784bb5c3 100644 --- a/src/context/identity_load_registry.rs +++ b/src/context/identity_load_registry.rs @@ -14,12 +14,14 @@ //! persisted by a load that errored. //! //! So each load reports its own phase here, and the registry is the single source -//! of truth for it. Loads are dispatched from several places — the Masternodes -//! form, Add Existing, the detail screen, MCP tools — so a record is stamped with -//! a [`IdentityLoadToken`] identifying the one load it belongs to, and every write -//! checks that stamp first. Without it a later submission could erase a running -//! load's record, and the two loads would then race each other's storage writes, -//! or publish each other's outcome. +//! of truth for it. Identity deletion and deliberate discovery skips also finish +//! through the same phase while holding the identity's exclusive claim. Loads are +//! dispatched from several places — the Masternodes form, Add Existing, the +//! detail screen, MCP tools — so a record is stamped with a [`IdentityLoadToken`] +//! identifying the one load it belongs to, and every write checks that stamp +//! first. Without it a later submission could erase a running load's record, and +//! the two loads would then race each other's storage writes, or publish each +//! other's outcome. //! //! The records also give a load exclusive use of its identity for its whole //! check → fetch → insert → seal span, which is what makes @@ -45,7 +47,7 @@ pub enum IdentityLoadPhase { Submitted, /// Running: the task holds this identity's exclusive claim. Running, - /// Finished, fully applied — the node is stored, with its keys as requested. + /// Finished and fully applied: stored, deliberately skipped, or deleted. Loaded, /// Finished with an error. The node may still have been persisted: the insert /// precedes the key seal, and a failed seal leaves the insert behind. Anything diff --git a/src/ui/dashpay/dashpay_screen.rs b/src/ui/dashpay/dashpay_screen.rs index fd60c954b..d7c21713f 100644 --- a/src/ui/dashpay/dashpay_screen.rs +++ b/src/ui/dashpay/dashpay_screen.rs @@ -210,7 +210,8 @@ impl ScreenLike for DashPayScreen { .contacts_list .contact_requests .display_task_error(error), - DashPaySubscreen::Profile | DashPaySubscreen::Payments => false, + DashPaySubscreen::Profile => self.profile_screen.display_task_error(error), + DashPaySubscreen::Payments => false, DashPaySubscreen::ProfileSearch => false, } } diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index b75802837..cf8bdbb0d 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -1,5 +1,6 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::dashpay::{MAX_AVATAR_URL_CHARS, ProfileFieldError}; @@ -59,6 +60,8 @@ pub struct ProfileScreen { loading: bool, saving: bool, // Track if we're saving vs loading profile_load_attempted: bool, + in_flight_profile_load: Option<(dash_sdk::platform::Identifier, u64)>, + profile_load_generation: u64, validation_errors: Vec, has_unsaved_changes: bool, original_display_name: String, @@ -92,6 +95,8 @@ impl ProfileScreen { loading: false, saving: false, profile_load_attempted: false, + in_flight_profile_load: None, + profile_load_generation: 0, validation_errors: Vec::new(), has_unsaved_changes: false, original_display_name: String::new(), @@ -168,7 +173,12 @@ impl ProfileScreen { } pub fn trigger_load_profile(&mut self) -> AppAction { + if self.in_flight_profile_load.is_some() { + return AppAction::None; + } if let Some(identity) = self.selected_identity.clone() { + self.in_flight_profile_load = + Some((identity.identity.id(), self.profile_load_generation)); self.loading = true; self.profile_load_attempted = true; AppAction::BackendTask(BackendTask::DashPayTask(Box::new( @@ -381,6 +391,7 @@ impl ProfileScreen { if response.changed() { // Reset state when identity changes + self.profile_load_generation = self.profile_load_generation.wrapping_add(1); self.profile = None; self.profile_load_attempted = false; self.loading = false; @@ -1046,9 +1057,35 @@ impl ProfileScreen { if matches!(message_type, MessageType::Error | MessageType::Warning) { self.loading = false; self.saving = false; + self.in_flight_profile_load = None; } } + fn invalidate_unloaded_identity(&mut self, identity_id: &dash_sdk::platform::Identifier) { + if self + .selected_identity + .as_ref() + .is_some_and(|identity| identity.identity.id() == *identity_id) + { + self.profile_load_generation = self.profile_load_generation.wrapping_add(1); + self.selected_identity = None; + self.selected_identity_string.clear(); + self.profile = None; + self.loading = false; + self.saving = false; + self.profile_load_attempted = false; + self.editing = false; + self.has_unsaved_changes = false; + } + } + + pub fn display_task_error(&mut self, error: &TaskError) -> bool { + if let TaskError::IdentityUnloadCleanupFailed { identity_id, .. } = error { + self.invalidate_unloaded_identity(identity_id); + } + false + } + pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { // Avatar results arrive independently of profile load/save; route them // without disturbing those loading states. @@ -1057,13 +1094,35 @@ impl ProfileScreen { return; } - // Always clear loading and saving states first - self.loading = false; - self.saving = false; - self.profile_load_attempted = true; + match &result { + BackendTaskSuccessResult::UnloadedIdentity(identity_id) => { + self.invalidate_unloaded_identity(identity_id); + return; + } + BackendTaskSuccessResult::RemovedIdentities { identity_ids, .. } => { + for identity_id in identity_ids { + self.invalidate_unloaded_identity(identity_id); + } + return; + } + _ => {} + } match result { BackendTaskSuccessResult::DashPayProfile(profile_data) => { + let Some((owner_id, generation)) = self.in_flight_profile_load.take() else { + return; + }; + self.loading = false; + let selected_id = self + .selected_identity + .as_ref() + .map(|identity| identity.identity.id()); + if generation != self.profile_load_generation || selected_id != Some(owner_id) { + self.profile_load_attempted = false; + return; + } + self.profile_load_attempted = true; if let Some((display_name, bio, avatar_url)) = profile_data { // Check if avatar URL changed - if so, we need to re-fetch the avatar let old_avatar_url = self.profile.as_ref().map(|p| p.avatar_url.clone()); @@ -1101,7 +1160,15 @@ impl ProfileScreen { // Don't show a message - let the UI show "Create Profile" button } } - BackendTaskSuccessResult::DashPayProfileUpdated(_identity_id) => { + BackendTaskSuccessResult::DashPayProfileUpdated(updated_identity_id) => { + self.saving = false; + if self + .selected_identity + .as_ref() + .is_none_or(|identity| identity.identity.id() != updated_identity_id) + { + return; + } // Profile was successfully created/updated; the upstream // mirror (`update_profile` → `dashpay_set_profile`) is the // authoritative write, so we only refresh local in-memory @@ -1144,3 +1211,63 @@ impl ProfileScreen { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::test_support::test_app_context; + use crate::model::qualified_identity::{IdentityStatus, IdentityType}; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, Identity}; + use std::collections::BTreeMap; + + fn qualified_identity(identity_id: Identifier) -> QualifiedIdentity { + QualifiedIdentity { + identity: Identity::create_basic_identity(identity_id, PlatformVersion::latest()) + .expect("create identity"), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: Vec::new(), + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + #[test] + fn late_profile_result_for_unloaded_identity_is_discarded() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let identity = qualified_identity(Identifier::from([0xD1; 32])); + let mut screen = ProfileScreen::new(ctx); + screen.selected_identity = Some(identity); + assert!(matches!( + screen.trigger_load_profile(), + AppAction::BackendTask(BackendTask::DashPayTask(_)) + )); + + let error = TaskError::IdentityUnloadCleanupFailed { + identity_id: Identifier::from([0xD1; 32]), + source: Box::new(TaskError::IdentityNotFound), + }; + assert!(!screen.display_task_error(&error)); + screen.display_task_result(BackendTaskSuccessResult::DashPayProfile(Some(( + "Stale name".to_string(), + "Stale bio".to_string(), + "https://example.invalid/stale.png".to_string(), + )))); + + assert!( + screen.profile.is_none(), + "a result started for an identity that is no longer displayed must be discarded" + ); + } +} diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 831ada2b8..222d99d75 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -231,8 +231,6 @@ pub enum DetailOutcome { None, /// Return to the card list (`‹ All masternodes`). Back, - /// The node was removed — return to the list and reload. - Removed, /// Push a reused screen / navigate. Boxed because `AppAction` is large. Forward(Box), } @@ -441,8 +439,8 @@ impl MasternodeDetailView { outcome = DetailOutcome::Forward(Box::new(action)); } ui.add_space(12.0); - if self.render_remove_section(ui, dark_mode) { - outcome = DetailOutcome::Removed; + if let Some(action) = self.render_remove_section(ui, dark_mode) { + outcome = DetailOutcome::Forward(Box::new(action)); } }); @@ -953,8 +951,8 @@ impl MasternodeDetailView { action } - /// Returns `true` once the node has been removed. - fn render_remove_section(&mut self, ui: &mut Ui, _dark_mode: bool) -> bool { + /// Dispatch the shared removal task after confirmation. + fn render_remove_section(&mut self, ui: &mut Ui, _dark_mode: bool) -> Option { let migration_in_progress = self.app_context.migration_status().state().is_in_progress(); if ui .add_enabled( @@ -978,66 +976,24 @@ impl MasternodeDetailView { ); } - let mut removed = false; + let mut action = None; if let Some(dialog) = self.remove_dialog.as_mut() { use crate::ui::components::component_trait::Component; let response = dialog.show(ui); if let Some(status) = response.inner.dialog_response { self.remove_dialog = None; if status == ConfirmationStatus::Confirmed { - removed = self.remove_node(ui.ctx()); + action = Some(Self::remove_node(self.identity.identity.id())); } } } - removed + action } - /// Delete the node and its associated voter identity from local storage. - /// Keep the detail view open when storage deletion does not happen. If only - /// follow-up cleanup fails, reconcile memory, close the removed view, and - /// surface the cleanup error. Voter cleanup failures remain non-fatal. - fn remove_node(&self, ctx: &egui::Context) -> bool { - let node_id = self.identity.identity.id(); - match self.app_context.unload_local_qualified_identity(&node_id) { - Ok(()) => self - .app_context - .reconcile_unloaded_identity_memory(&node_id), - Err(error) if error.identity_was_removed() => { - self.app_context - .reconcile_unloaded_identity_memory(&node_id); - MessageBanner::set_global( - ctx, - "The masternode was removed, but some local data could not be cleaned up. Load and remove it again to retry.", - MessageType::Error, - ) - .with_details(error); - } - Err(error) => { - MessageBanner::set_global( - ctx, - "This masternode couldn't be removed from this device. Try again in a moment.", - MessageType::Error, - ) - .with_details(error); - return false; - } - } - if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() { - let voter_id = voter.id(); - let voter_result = self.app_context.delete_local_qualified_identity(&voter_id); - if voter_result.as_ref().is_ok() - || voter_result - .as_ref() - .is_err_and(|error| error.identity_was_removed()) - { - self.app_context - .reconcile_unloaded_identity_memory(&voter_id); - } - if let Err(error) = voter_result { - tracing::warn!(error = ?error, "Failed to remove voter identity"); - } - } - true + fn remove_node(node_id: dash_sdk::platform::Identifier) -> AppAction { + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RemoveIdentity { + identity_id: node_id, + })) } } @@ -1063,6 +1019,17 @@ mod tests { ); } + #[test] + fn remove_node_dispatches_the_shared_identity_removal_task() { + let node_id = dash_sdk::platform::Identifier::from([0xD2; 32]); + assert!(matches!( + MasternodeDetailView::remove_node(node_id), + AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::RemoveIdentity { identity_id } + )) if identity_id == node_id + )); + } + /// Build a masternode key with a chosen id / purpose / disabled state. fn mn_key( id: dash_sdk::dpp::identity::KeyID, diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index bae36f9a6..79effc0fb 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -389,11 +389,6 @@ impl MasternodesScreen { self.view = MasternodesView::List; AppAction::None } - DetailOutcome::Removed => { - self.view = MasternodesView::List; - self.reload(); - AppAction::None - } DetailOutcome::Forward(action) => *action, } } @@ -551,7 +546,17 @@ impl ScreenLike for MasternodesScreen { } } - fn display_task_result(&mut self, _result: crate::backend_task::BackendTaskSuccessResult) { + fn display_task_result(&mut self, result: crate::backend_task::BackendTaskSuccessResult) { + let removed_open_node = matches!( + (&self.view, &result), + ( + MasternodesView::Detail(detail), + crate::backend_task::BackendTaskSuccessResult::RemovedIdentities { + identity_ids, + .. + } + ) if identity_ids.contains(&detail.node_id()) + ); self.reload(); // Settle the submitted load against the phase its task reported. This // screen also receives detail-view results (voting, RefreshIdentity) and @@ -562,7 +567,9 @@ impl ScreenLike for MasternodesScreen { // Add-voting-key merge, a RefreshIdentity) just updated the store. // Re-open the detail view for that node so the on-screen view reflects // the fresh data instead of the stale clone captured at open time. - if let MasternodesView::Detail(detail) = &self.view { + if removed_open_node { + self.view = MasternodesView::List; + } else if let MasternodesView::Detail(detail) = &self.view { let node_id = detail.node_id(); self.open_detail(node_id); } @@ -672,6 +679,37 @@ mod tests { .expect("seed masternode"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn removal_result_closes_detail_while_failure_keeps_it_open() { + use crate::backend_task::BackendTaskSuccessResult; + use crate::backend_task::error::TaskError; + + let (ctx, _tmp) = offline_ctx().await; + let node_id = Identifier::from([0xD3; 32]); + seed_masternode(&ctx, 0xD3, None); + let mut screen = MasternodesScreen::new(&ctx); + screen.open_detail(node_id); + + assert!(!screen.display_task_error(&TaskError::WalletBackendNotYetWired)); + assert!( + matches!(screen.view, MasternodesView::Detail(_)), + "a failed removal must keep the detail view open" + ); + + screen.display_task_result(BackendTaskSuccessResult::RemovedIdentities { + identity_ids: vec![node_id], + primary_cleanup_failed: false, + associated_cleanup_failed: false, + associated_removal_failed: false, + }); + assert!( + matches!(screen.view, MasternodesView::List), + "a successful shared removal result must close the detail view" + ); + + ctx.wallet_backend().expect("backend").shutdown().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn nodes_are_sorted_case_insensitively_by_display_heading() { let (ctx, _tmp) = offline_ctx().await; From 5f15f85f10bb956ac15e6b27c0a9460965fc6c22 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:37:51 +0000 Subject: [PATCH 19/46] fix: give unload its own error for a competing load claim (PROJ-003) delete_local_qualified_identity_inner shares the load registry's per-identity exclusive claim via begin_identity_load(), which is the correct locking mechanism. But when another load (e.g. background discovery at startup) already holds the claim, the bare `?` propagated TaskError::IdentityLoadInProgress verbatim -- copy written for the load screen ("wait, then load it again") -- straight out of the UNLOAD path. A user who pressed "Unload this identity" was told to wait and then load, the inverse of what they asked for, and the message said "node" (masternode vocabulary) for an Identity Hub identity. Adds TaskError::IdentityBusyWithLoad with unload-worded copy naming the identity, and maps IdentityLoadInProgress into it inside the delete path only; IdentityLoadInProgress itself is untouched for the load paths it was written for. Also fixes the adjacent (false, false, true) banner in identities_screen.rs, which told users to "Restart the app" for the same transient condition -- now "Wait a moment". Job: task-ms0nalbj-9htbvj (Codex Sol, --effort high). Verification: identity_unload_respects_an_in_flight_load_claim, second_concurrent_unload_of_the_same_identity_is_rejected_without_side_effects, identity_removal_messages_distinguish_cleanup_outcomes all pass (test names confirmed present and green in the raw log, not just exit 0); cargo fmt --all -- --check and cargo clippy --all-features --bin dash-evo-tool -- -D warnings both clean. --- src/backend_task/error.rs | 6 ++++++ src/backend_task/identity/unload_identity.rs | 6 +++--- src/context/identity_db.rs | 13 ++++++++++--- src/ui/identities/identities_screen.rs | 4 +++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 06e23af47..14ad2f73f 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -682,6 +682,12 @@ pub enum TaskError { source: Box, }, + /// Another load currently owns this identity's exclusive claim. + #[error( + "The identity {identity_id} is being updated right now. Wait a moment and try unloading it again." + )] + IdentityBusyWithLoad { identity_id: Identifier }, + /// A user's choice to keep an unloaded identity off this device could not /// be read or saved in the local database. #[error( diff --git a/src/backend_task/identity/unload_identity.rs b/src/backend_task/identity/unload_identity.rs index 22a6219cf..95679f796 100644 --- a/src/backend_task/identity/unload_identity.rs +++ b/src/backend_task/identity/unload_identity.rs @@ -294,7 +294,7 @@ mod tests { /// `delete_local_qualified_identity_inner`), and — because it never got /// past `begin_identity_load` — must leave in-memory state completely /// untouched: `error.identity_was_removed()` is false for - /// `IdentityLoadInProgress`, so `unload_identity()` returns before ever + /// `IdentityBusyWithLoad`, so `unload_identity()` returns before ever /// calling `reconcile_unloaded_identity_memory`. A rejected concurrent /// unload evicting the wallet cache or clearing the selection anyway /// would be a real bug: it would desync the UI from storage, which still @@ -361,9 +361,9 @@ mod tests { assert!( matches!( second_unload_error, - TaskError::IdentityLoadInProgress { identity_id } if identity_id == target_id + TaskError::IdentityBusyWithLoad { identity_id } if identity_id == target_id ), - "the rejection must be IdentityLoadInProgress, got {second_unload_error:?}" + "the rejection must be IdentityBusyWithLoad, got {second_unload_error:?}" ); // Nothing the rejected call touched: storage, wallet cache, and diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 960363f58..961307ab3 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -988,7 +988,14 @@ impl AppContext { remember_unload: bool, ) -> std::result::Result<(), TaskError> { // The load registry provides the existing per-identity exclusive claim. - let load_guard = self.begin_identity_load(*identifier, None)?; + let load_guard = + self.begin_identity_load(*identifier, None) + .map_err(|error| match error { + TaskError::IdentityLoadInProgress { identity_id } => { + TaskError::IdentityBusyWithLoad { identity_id } + } + other => other, + })?; let _migration_guard = self .migration_run .try_lock() @@ -2542,9 +2549,9 @@ mod tests { assert!( matches!( ctx.delete_local_qualified_identity(&target_id), - Err(TaskError::IdentityLoadInProgress { identity_id }) if identity_id == target_id + Err(TaskError::IdentityBusyWithLoad { identity_id }) if identity_id == target_id ), - "deletion must not race a load of the same identity" + "deletion must surface an unload-specific error without racing the load" ); assert!( ctx.get_local_qualified_identity(&target_id) diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 8c05563e3..a161c16a4 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -68,7 +68,7 @@ fn identity_removal_message( MessageType::Warning, ), (false, false, true) => ( - "The identity was removed, but its associated voter identity is still on this device. Restart the app, then load and remove the identity again to retry.", + "The identity was removed, but its associated voter identity is still on this device. Wait a moment, then load and remove the identity again to retry.", MessageType::Warning, ), (true, false, true) => ( @@ -1314,6 +1314,8 @@ mod tests { let (message, message_type) = identity_removal_message(false, false, true); assert!(message.contains("associated voter identity is still on this device")); assert!(!message.contains("local data could not be cleaned up")); + assert!(message.contains("Wait a moment")); + assert!(!message.contains("Restart the app")); assert_eq!(message_type, crate::ui::MessageType::Warning); let (message, message_type) = identity_removal_message(true, false, true); From 6ca1557dbeef6014eda4d36ff03b1f315bc5274a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:48:49 +0000 Subject: [PATCH 20/46] docs(identity): document passwordless unload decision (SEC-007) Co-Authored-By: Codex GPT-5 --- src/backend_task/identity/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 854b12a93..77134df30 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -504,6 +504,13 @@ pub enum IdentityTask { }, /// Permanently remove one identity's keys and local device state while /// leaving the Platform identity itself unchanged. + /// + /// Unload deliberately requires no per-identity password, even for Tier-2 + /// protected identities. Deletion exposes no key material, and a password + /// gate is not a security boundary for someone with device and app access + /// who can already destroy the local files. [`Self::UnprotectIdentityKeys`] + /// verifies the password because it retains the keys while removing their + /// protection; deletion needs no equivalent proof. UnloadIdentity { /// The identity to unload from this device. identity_id: Identifier, From 1925b3d6b8c8d5748339d472c537737494d88b5a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:48:54 +0000 Subject: [PATCH 21/46] fix(identity): preserve retryable unload cleanup (SEC-001, SEC-003) Co-Authored-By: Codex GPT-5 --- src/context/identity_db.rs | 86 ++++++++++++--------------- src/context/wallet_lifecycle/spv.rs | 17 +++++- src/context/wallet_lifecycle/tests.rs | 56 +++++++---------- 3 files changed, 76 insertions(+), 83 deletions(-) diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 961307ab3..89c650aad 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1059,16 +1059,18 @@ impl AppContext { *identifier, backend.identity_meta().delete(self.network, &id), ); - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - self.clear_identity_vault_keys(&kv, &id), - ); - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - purge_identity_scope(&kv, &id), - ); + match self.clear_identity_vault_keys(&kv, &id) { + Ok(()) => keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + purge_identity_scope(&kv, &id), + ), + // The identity blob inventories its vault labels. Keep it when the + // clear fails so a later retry can finish deleting those keys. + Err(error) => { + keep_first_unload_cleanup_error(&mut cleanup_error, *identifier, Err(error)) + } + } if let Some(error) = cleanup_error { return Err(error); } @@ -2626,28 +2628,8 @@ mod tests { backend.shutdown().await; } - /// QA (issue #889 review): the same fault as - /// `deletion_fault_after_index_removal_leaves_no_visible_zombie` — index - /// removed, `clear_identity_vault_keys` then fails because the corrupted - /// blob can no longer be decoded to learn which vault labels to erase — - /// but followed one step further, to the vault itself and to the - /// "delete all local data" sweep. - /// - /// Requirement (CHANGELOG "Unload an identity from this device": "It - /// removes every piece of locally stored data for that identity (keys, - /// ...)"; the F60 comment in `clear_network_database`: "a full wipe must - /// remove [Tier-1 keyless identity keys] as well"): a private key must - /// never survive both a failed identity deletion AND a subsequent - /// "delete all local data" sweep. `purge_identity_scope` currently runs - /// unconditionally after a `clear_identity_vault_keys` failure and - /// deletes the identity blob — per `IdentityKeyView`'s own doc comment - /// "the only on-disk marker that the key exists" — permanently - /// orphaning the vault entry: `clear_network_database`'s per-identity - /// sweep enumerates exclusively via `local_identity_ids()`, which no - /// longer lists this identity once its index entry (and blob) are gone. - /// EXPECTED (currently FAILS): the vault key must still be reachable — - /// i.e. cleared, or at minimum still discoverable/retryable — after the - /// full-wipe sweep. This test currently fails, proving the leak. + /// A failed vault-key clear must retain the identity blob that inventories + /// its labels, allowing a later retry to finish without orphaning the key. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn deletion_fault_after_index_removal_must_not_permanently_orphan_vault_key() { use crate::app::TaskResult; @@ -2678,11 +2660,14 @@ mod tests { "the vault key must exist before the faulted deletion" ); - // Corrupt the stored blob in place, exactly as the sibling test does, - // so `clear_identity_vault_keys` fails to decode it and never learns - // which vault labels belong to this identity. let id_buf = target_id.to_buffer(); let kv = ctx.det_kv().expect("det kv"); + let stored_before_fault = kv + .get::(DetScope::Identity(&id_buf), IDENTITY_KEY) + .expect("read stored identity") + .expect("stored identity exists"); + // Corrupt the stored blob in place so the first clear cannot decode + // the inventory of vault labels. kv.put(DetScope::Identity(&id_buf), IDENTITY_KEY, &stored("User")) .expect("corrupt the stored blob"); @@ -2696,25 +2681,30 @@ mod tests { .contains(&target_id), "precondition: the identity is already hidden from the index" ); + assert!( + kv.get::(DetScope::Identity(&id_buf), IDENTITY_KEY) + .expect("read retained inventory") + .is_some(), + "a vault-clear failure must retain the only on-disk inventory of key labels" + ); - // Simulate clear_network_database's (F60 "delete all local data") own - // per-identity sweep, which enumerates strictly through - // `local_identity_ids()` (see context/wallet_lifecycle/spv.rs). - for owner in ctx.local_identity_ids().expect("read index for sweep") { - let _ = ctx.delete_local_qualified_identity(&owner); - } + // Repair the transiently unreadable inventory and retry by the known + // identity id, as the unload caller can after the reported failure. + kv.put( + DetScope::Identity(&id_buf), + IDENTITY_KEY, + &stored_before_fault, + ) + .expect("restore stored identity"); + ctx.delete_local_qualified_identity(&target_id) + .expect("retry identity deletion"); assert!( target_vault .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) - .expect("read target key after full-wipe sweep") + .expect("read target key after retry") .is_none(), - "the vault key must not permanently survive a faulted deletion followed by the \ - 'delete all local data' sweep — clear_network_database documents that a full \ - wipe removes every identity's private-key material, but purge_identity_scope \ - deleted the blob (the only on-disk record of which vault labels belonged to \ - this identity) before the vault-key clear it depends on ever ran, and the \ - sweep can no longer find this identity at all once its index entry is gone" + "the retained inventory must let a retry delete the vault key" ); backend.shutdown().await; diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index d4848e66b..e471b21e8 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -3,6 +3,9 @@ use super::*; +const IDENTITY_WIPE_ATTEMPTS: usize = 5; +const IDENTITY_WIPE_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(25); + impl AppContext { /// Delete the cached chain-sync data (headers, filters, blocks, masternode /// state, peers) for this network so the next connection re-syncs from @@ -77,7 +80,19 @@ impl AppContext { // Wipe each identity's vault keys and det:identity:* records too — // Tier-1 keyless identity keys (incl. masternode voting/owner/payout) // are plaintext-recoverable, so a full wipe must remove them as well. - if let Err(e) = self.delete_local_qualified_identity(&owner) { + let mut attempts_remaining = IDENTITY_WIPE_ATTEMPTS; + let deletion_result = loop { + match self.delete_local_qualified_identity(&owner) { + Err(TaskError::IdentityBusyWithLoad { .. }) + if attempts_remaining > 1 => + { + attempts_remaining -= 1; + tokio::time::sleep(IDENTITY_WIPE_RETRY_DELAY).await; + } + result => break result, + } + }; + if let Err(e) = deletion_result { tracing::warn!( owner = %owner, "Identity private-key wipe failed during clear: {e:?}" diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 12dc2fa54..878cf6a31 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -2126,14 +2126,9 @@ async fn clear_network_database_wipes_local_identity_private_keys() { .await; } -/// QA (issue #889 review): `delete_local_qualified_identity_inner` now opens -/// with `begin_identity_load` (identity_db.rs:985), so the per-identity wipe -/// loop in `clear_network_database` collides with any other outstanding claim -/// on that identity — e.g. a `Background` discovery pass mid-scan. The claim -/// is held directly here to simulate that collision deterministically instead -/// of racing a real discovery task. +/// A network clear retries when a background load briefly owns the identity. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn clear_network_database_reports_incomplete_when_a_load_claim_is_outstanding() { +async fn clear_network_database_retries_until_a_load_claim_clears() { use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use crate::model::qualified_identity::{ @@ -2192,50 +2187,43 @@ async fn clear_network_database_reports_incomplete_when_a_load_claim_is_outstand "precondition: the identity private key is in the vault before clear" ); - // Simulate a concurrent Background discovery pass holding this - // identity's exclusive load claim for the whole span of the wipe. + // Hold the exclusive claim long enough for the first wipe attempt to fail, + // then release it within the bounded retry window. let discovery_claim = ctx .begin_identity_load(identity_id, None) .expect("simulate an outstanding discovery claim on this identity"); + let release_claim = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(40)).await; + drop(discovery_claim); + }); let result = ctx.clear_network_database().await; + release_claim.await.expect("release discovery claim"); - match result { - Err(TaskError::WalletDataClearIncomplete { - failed, - first_error, - }) => { - assert!(failed >= 1, "the claim collision must count as a failure"); - assert!( - matches!(*first_error, TaskError::IdentityLoadInProgress { identity_id: id } if id == identity_id), - "the collision must surface as IdentityLoadInProgress, got {first_error:?}" - ); - } - other => panic!("an outstanding load claim must make the clear incomplete, got {other:?}"), - } + assert!( + result.is_ok(), + "a claim released within the retry window must not make the clear incomplete: {result:?}" + ); - // The colliding identity's own data must survive untouched: the wipe for - // THIS identity never ran. - assert_eq!( - ctx.local_identity_ids().expect("list ids after clear"), - vec![identity_id], - "the identity whose claim collided must remain locally stored" + assert!( + ctx.local_identity_ids() + .expect("list ids after clear") + .is_empty(), + "the retried identity must be removed from local storage" ); assert!( view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id) .expect("vault read after clear") - .is_some(), - "the colliding identity's private key must survive the incomplete clear" + .is_none(), + "the retried wipe must delete the identity's private key" ); - // The in-memory wallet maps are still torn down unconditionally, so the - // user sees no wallets even though on-disk state is incomplete. + // The in-memory wallet maps are torn down after the successful retry. assert!( ctx.wallets().read().expect("read wallets").is_empty(), - "in-memory wallets must still be cleared despite the incomplete wipe" + "in-memory wallets must be cleared after the wipe" ); - drop(discovery_claim); ctx.wallet_backend() .expect("backend wired") .shutdown() From fc0972117c3dacada2590eb7a86dc7ae0b105366 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:52:57 +0000 Subject: [PATCH 22/46] fix(identity): disclose scheduled vote cancellation (PROJ-002) Co-Authored-By: Codex GPT-5 --- CHANGELOG.md | 16 ++++--- docs/user-stories.md | 6 ++- src/context/identity_db.rs | 57 +++++++++++++++++++++++++ src/ui/identity/settings.rs | 85 ++++++++++++++++++++++++++++--------- 4 files changed, 135 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b909db603..74038aba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Unload an identity from this device**: Identity Hub → Settings now has a working "Unload this identity from this device" action. It removes the identity from the app on this device and clears the local keys, profile - information, and DashPay details that the app can identify for it, while - leaving your other identities untouched. If cleanup cannot finish, the app - reports that clearly so you can retry. The identity itself is unaffected on - the network. Wallet-derived private keys can be restored from the wallet. To - load the identity again, you need recovery information for any keys stored - only on this device. Unloaded identities now stay unloaded after automatic - wallet discovery, and delayed profile results are ignored after an unload. + information, DashPay details, and queued scheduled DPNS votes that the app can + identify for it, while leaving your other identities untouched. The + confirmation names how many scheduled votes will be cancelled. If cleanup + cannot finish, the app reports that clearly so you can retry. The identity + itself is unaffected on the network. Wallet-derived private keys can be + restored from the wallet. To load the identity again, you need recovery + information for any keys stored only on this device. Unloaded identities now + stay unloaded after automatic wallet discovery, and delayed profile results + are ignored after an unload. - **Automatic Platform node refresh during upgrades**: migrating a pre-1.0 installation now triggers a best-effort Mainnet or Testnet node refresh. diff --git a/docs/user-stories.md b/docs/user-stories.md index 8245f96e2..caa669ea8 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -653,8 +653,10 @@ As a user, I want to unload one identity from this device so that I can recover - The Identity Hub asks for confirmation before unloading. If the identity is wallet-derived, it explains that the identity can be loaded again from the wallet's recovery seed. Otherwise, it warns that keys stored only on this - device are permanently deleted and require separate recovery information. -- Unloading removes only the selected identity's local keys, metadata, DashPay overlays, and device record while leaving the Platform identity unchanged. + device are permanently deleted and require separate recovery information. If + scheduled votes are queued, the confirmation states how many will be + cancelled. +- Unloading removes only the selected identity's local keys, metadata, DashPay overlays, queued scheduled votes, and device record while leaving the Platform identity unchanged. - Other identities on the same wallet and the wallet's recovery seed remain available. --- diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 89c650aad..2df7e19a0 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1231,6 +1231,15 @@ impl AppContext { Ok(()) } + /// Count the scheduled votes queued for one identity on this network. + pub fn scheduled_vote_count_for_identity( + &self, + voter: &Identifier, + ) -> std::result::Result { + let kv = self.det_kv()?; + Ok(scheduled_vote_keys(&kv, &voter.to_buffer())?.len()) + } + /// Fetch every scheduled vote queued for this network from the /// wallet k/v store, across all voters in the Global voter index. pub fn get_scheduled_votes(&self) -> std::result::Result, TaskError> { @@ -1756,6 +1765,54 @@ mod tests { assert_eq!(voters, vec![v1, v2]); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn scheduled_vote_count_for_identity_counts_only_that_voters_queue() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let target = id(0x61); + let other = id(0x62); + let kv = ctx.det_kv().expect("det kv"); + for (voter, name) in [(target, "alpha"), (target, "beta"), (other, "gamma")] { + kv.put( + DetScope::Identity(&voter), + &scheduled_vote_key(name), + &StoredScheduledVote { + voter_id: voter, + contested_name: name.to_string(), + choice: StoredVoteChoice::Lock, + unix_timestamp: 0, + executed_successfully: false, + }, + ) + .expect("store scheduled vote"); + } + + assert_eq!( + ctx.scheduled_vote_count_for_identity(&Identifier::from(target)) + .expect("count target votes"), + 2 + ); + assert_eq!( + ctx.scheduled_vote_count_for_identity(&Identifier::from(id(0x63))) + .expect("count absent voter"), + 0 + ); + + ctx.wallet_backend() + .expect("backend wired") + .shutdown() + .await; + } + #[test] fn delete_scheduled_votes_for_voter_drains_scope_and_prunes_index() { let kv = empty_kv(); diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 5d8c12a5e..969248a8f 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -97,6 +97,8 @@ const ALIAS_HINT: &str = "For example: My main identity"; const ALIAS_SAVED: &str = "Name saved on this device."; const ALIAS_SAVE_FAILED: &str = "This name could not be saved on your device. Try again in a moment."; +const UNLOAD_DETAILS_LOAD_FAILED: &str = + "The unload details could not be loaded. Try again in a moment."; const TIP_PROTX_COPY: &str = "Copy the masternode ID to your clipboard."; // Marker strings for controls without a matching backend task. Surfaced in // disabled_tooltip and as a prefix on the row so users know it is a coming @@ -753,17 +755,32 @@ impl SettingsTab { .clickable_tooltip(identity_unload_tip(identity)); if unload.clicked() { let target_id = identity.identity.id(); - self.confirm_unload = Some(PendingIdentityUnload { - dialog: ConfirmationDialog::new( - "Unload this identity", - identity_unload_confirmation_message(identity), - ) - .confirm_text(Some("Permanently unload")) - .cancel_text(Some("Keep identity")) - .danger_mode(true) - .blocks_input(true), - target_id, - }); + match app_context.scheduled_vote_count_for_identity(&target_id) { + Ok(scheduled_vote_count) => { + self.confirm_unload = Some(PendingIdentityUnload { + dialog: ConfirmationDialog::new( + "Unload this identity", + identity_unload_confirmation_message( + identity, + scheduled_vote_count, + ), + ) + .confirm_text(Some("Permanently unload")) + .cancel_text(Some("Keep identity")) + .danger_mode(true) + .blocks_input(true), + target_id, + }); + } + Err(error) => { + MessageBanner::set_global( + ui.ctx(), + UNLOAD_DETAILS_LOAD_FAILED, + MessageType::Error, + ) + .with_details(&error); + } + } } }); @@ -996,30 +1013,46 @@ fn identity_unload_tip_for(recovery_information_required: bool) -> &'static str } } -fn identity_unload_confirmation_message(identity: &QualifiedIdentity) -> String { +fn identity_unload_confirmation_message( + identity: &QualifiedIdentity, + scheduled_vote_count: usize, +) -> String { let identity_label = identity_unload_label(identity); identity_unload_confirmation_message_for( &identity_label, identity.requires_recovery_information_after_unload(), + scheduled_vote_count, ) } fn identity_unload_confirmation_message_for( identity_label: &str, recovery_information_required: bool, + scheduled_vote_count: usize, ) -> String { - if recovery_information_required { - format!( + match (recovery_information_required, scheduled_vote_count > 0) { + (true, true) => format!( + "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + deleting its private keys and local data. It remains on Dash Platform, but you will \ + need its recovery information to load it again. This also cancels \ + {scheduled_vote_count} scheduled vote(s)." + ), + (true, false) => format!( "Identity \"{identity_label}\" will be permanently unloaded from this device, \ deleting its private keys and local data. It remains on Dash Platform, but you will \ need its recovery information to load it again." - ) - } else { - format!( + ), + (false, true) => format!( + "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + deleting its local data. It remains on Dash Platform, and its wallet-derived private \ + keys can be restored when you load it again. This also cancels \ + {scheduled_vote_count} scheduled vote(s)." + ), + (false, false) => format!( "Identity \"{identity_label}\" will be permanently unloaded from this device, \ deleting its local data. It remains on Dash Platform, and its wallet-derived private \ keys can be restored when you load it again." - ) + ), } } @@ -1260,7 +1293,7 @@ mod tests { #[test] fn unload_dialog_omits_recovery_warning_for_wallet_derived_keys() { assert_eq!( - identity_unload_confirmation_message_for("Wallet identity", false), + identity_unload_confirmation_message_for("Wallet identity", false, 0), "Identity \"Wallet identity\" will be permanently unloaded from this device, \ deleting its local data. It remains on Dash Platform, and its wallet-derived \ private keys can be restored when you load it again." @@ -1268,6 +1301,18 @@ mod tests { assert_eq!(identity_unload_tip_for(false), TIP_UNLOAD_WALLET_DERIVED); } + #[test] + fn unload_dialog_mentions_scheduled_votes_only_when_queued() { + assert!( + identity_unload_confirmation_message_for("Voting identity", true, 3) + .contains("This also cancels 3 scheduled vote(s).") + ); + assert!( + !identity_unload_confirmation_message_for("Voting identity", true, 0) + .contains("scheduled vote") + ); + } + #[test] fn unload_dialog_warns_about_recovery_information_for_mixed_keys() { let mut identity = qualified_identity_with(12, Some("Mixed identity")); @@ -1292,7 +1337,7 @@ mod tests { ); assert_eq!( - identity_unload_confirmation_message(&identity), + identity_unload_confirmation_message(&identity, 0), "Identity \"Mixed identity\" will be permanently unloaded from this device, deleting \ its private keys and local data. It remains on Dash Platform, but you will need its \ recovery information to load it again." From d19be53b2dc8f0eb4f315acd93be8316d15027a2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:37:17 +0000 Subject: [PATCH 23/46] fix(identity): retry unload on busy load claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- src/backend_task/identity/discover_identities.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index 3c643e57d..6587d7e0c 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -681,7 +681,7 @@ mod tests { let mut attempted_tx = Some(attempted_tx); loop { match unload_ctx.unload_identity(identity_id) { - Err(TaskError::IdentityLoadInProgress { .. }) => { + Err(TaskError::IdentityBusyWithLoad { .. }) => { if let Some(attempted_tx) = attempted_tx.take() { let _ = attempted_tx.send(()); } From 38072365652549f4b5d1d450851c7fa24e6076df Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:41:06 +0000 Subject: [PATCH 24/46] fix(wallet): clear forgotten identities on full wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- src/context/identity_db.rs | 7 +++++++ src/context/wallet_lifecycle/spv.rs | 4 ++++ src/context/wallet_lifecycle/tests.rs | 22 +++++----------------- src/database/forgotten_identities.rs | 10 ++++++++++ 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 2df7e19a0..223735316 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -982,6 +982,13 @@ impl AppContext { .map_err(|source| TaskError::ForgottenIdentityStorage { source }) } + /// Clear every discovery block for the active network. + pub(crate) fn clear_all_forgotten_identities(&self) -> std::result::Result<(), TaskError> { + self.db + .clear_all_forgotten_identities(self.network) + .map_err(|source| TaskError::ForgottenIdentityStorage { source }) + } + fn delete_local_qualified_identity_inner( &self, identifier: &Identifier, diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index e471b21e8..3bb100185 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -74,6 +74,10 @@ impl AppContext { failures.push(TaskError::DashpaySidecarStorage { source }); } } + if let Err(error) = self.clear_all_forgotten_identities() { + tracing::warn!(error = ?error, "Forgotten identity marker clear failed"); + failures.push(error); + } match self.local_identity_ids() { Ok(owners) => { for owner in owners { diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 878cf6a31..124d083cb 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -2360,18 +2360,10 @@ async fn clear_network_database_reports_incomplete_when_shielded_clear_fails() { } } -/// QA (issue #889 review): the v39 `forgotten_identities` table -/// (`database/forgotten_identities.rs`) lives in DET's own `data.db`, not in -/// `platform-wallet.sqlite`. `clear_network_database`'s F60 "delete all local -/// data" sweep never touches `self.db` at all — only the wallet backend's -/// vault/KV/wallet state — so a forgotten-identity marker recorded before a -/// full wipe survives it untouched. A user who unloads an identity, then -/// later runs "Clear all wallet data" expecting a genuinely fresh start, and -/// re-imports the same seed finds that identity silently un-rediscoverable — -/// automatic and wallet-unlock discovery both skip anything marked forgotten, -/// with no UI surface that shows the marker still exists. +/// "Delete all local data" clears forgotten-identity markers so re-importing a +/// wallet can rediscover identities that were unloaded before the wipe. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn clear_network_database_leaves_forgotten_identity_markers_in_place() { +async fn clear_network_database_clears_forgotten_identity_markers() { use dash_sdk::platform::Identifier; let (ctx, sender, _tmp) = offline_testnet_context(); @@ -2394,13 +2386,9 @@ async fn clear_network_database_leaves_forgotten_identity_markers_in_place() { .expect("clear_network_database should succeed with nothing else to wipe"); assert!( - ctx.is_identity_forgotten(&identity_id) + !ctx.is_identity_forgotten(&identity_id) .expect("read marker after wipe"), - "BUG: 'Clear all wallet data' is expected to clear the forgotten_identities table, \ - but this assertion documents that the marker recorded before the wipe currently \ - survives it untouched, silently blocking rediscovery of that identity after a full \ - wipe and reimport of the same seed. Flipping this assertion is the signal that the \ - gap has been closed.", + "the full wipe must clear forgotten-identity markers", ); ctx.wallet_backend() diff --git a/src/database/forgotten_identities.rs b/src/database/forgotten_identities.rs index 7f0d94a38..f331be583 100644 --- a/src/database/forgotten_identities.rs +++ b/src/database/forgotten_identities.rs @@ -46,6 +46,16 @@ impl Database { Ok(()) } + /// Clear every deliberately unloaded identity marker on one network. + pub(crate) fn clear_all_forgotten_identities(&self, network: Network) -> rusqlite::Result<()> { + self.execute( + "DELETE FROM forgotten_identities + WHERE network = ?1", + params![network.to_string()], + )?; + Ok(()) + } + /// Whether an identity is deliberately unloaded on one network. pub(crate) fn is_identity_forgotten( &self, From 6f0abf7c6c32791d91e19a641203a3e72cdac3f2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:19:25 +0000 Subject: [PATCH 25/46] fix(identity): restore unload cleanup retries (QA-001, QA-002) Recover forgotten unindexed identities before explicit reloads and full wipes, preserve retry markers across failures, and invalidate profile caches for bulk removals. Co-Authored-By: Claude GPT-5.6 --- src/backend_task/error.rs | 2 +- src/backend_task/identity/load_identity.rs | 17 +- src/context/identity_db.rs | 557 ++++++++++++++++++--- src/context/wallet_lifecycle/spv.rs | 203 +++++++- src/database/forgotten_identities.rs | 53 +- src/ui/identity/hub_screen.rs | 41 ++ 6 files changed, 782 insertions(+), 91 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index c63b1bcca..debf3b5ea 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -684,7 +684,7 @@ pub enum TaskError { /// an identity. Cleanup continues after failures, but only the first failure /// is preserved in the nested typed error for logs. #[error( - "Some local data for identity {identity_id} could not be fully removed. Load the identity again, then unload it to retry." + "Some local data for identity {identity_id} could not be fully removed. Wait a moment and try again." )] IdentityUnloadCleanupFailed { identity_id: Identifier, diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 81539a0c9..033f1de1e 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -180,16 +180,16 @@ impl AppContext { // before any network fetch — so the existing node's alias/keys/protection // tier are never silently overwritten. Checked here, at the storage // layer, so every `RejectIfExists` caller is guarded uniformly. - let existing_stored = self.get_local_qualified_identity(&identity_id)?; - match load_mode { - IdentityLoadMode::RejectIfExists - if existing_stored - .as_ref() - .is_some_and(|identity| !is_bare_placeholder(identity)) => + let mut existing_stored = self.get_local_qualified_identity(&identity_id)?; + if load_mode == IdentityLoadMode::RejectIfExists { + if self.prepare_stuck_unload_cleanup_for_reload(&identity_id.to_buffer())? { + existing_stored = None; + } else if existing_stored + .as_ref() + .is_some_and(|identity| !is_bare_placeholder(identity)) { return Err(TaskError::DuplicateProTxHash { identity_id }); } - _ => {} } // An in-place merge into a password-protected (Tier-2) node must @@ -1192,6 +1192,9 @@ mod tests { let identity_id = qi.identity.id(); ctx.insert_local_qualified_identity(&qi, &None) .expect("insert first masternode identity"); + ctx.db() + .record_forgotten_identity(Network::Testnet, &identity_id) + .expect("record stale forgotten marker"); let input = IdentityInputToLoad { identity_id_input: identity_id.to_string(Encoding::Hex), diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 223735316..9fe45de7f 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -443,9 +443,12 @@ fn encode_identity_blob_vault_first( fn purge_identity_scope(kv: &DetKv, id: &[u8; 32]) -> std::result::Result<(), TaskError> { let scope = DetScope::Identity(id); - kv.delete(scope, IDENTITY_KEY).map_err(identity_err)?; + // The identity blob is the recovery inventory for its vault keys. Delete + // auxiliary records first and the blob last so a partial purge remains + // discoverable and retryable. kv.delete(scope, TOP_UPS_KEY).map_err(top_up_err)?; - delete_scheduled_votes_for_voter(kv, id) + delete_scheduled_votes_for_voter(kv, id)?; + kv.delete(scope, IDENTITY_KEY).map_err(identity_err) } /// Read the Global scheduled-vote voter index. Returns an empty vector @@ -982,11 +985,113 @@ impl AppContext { .map_err(|source| TaskError::ForgottenIdentityStorage { source }) } - /// Clear every discovery block for the active network. - pub(crate) fn clear_all_forgotten_identities(&self) -> std::result::Result<(), TaskError> { - self.db - .clear_all_forgotten_identities(self.network) - .map_err(|source| TaskError::ForgottenIdentityStorage { source }) + /// Finish cleanup for a forgotten, unindexed identity whose blob remains. + /// + /// Returns `Ok(true)` after cleanup, `Ok(false)` for a non-ghost state, and + /// preserves the blob and marker when vault-key clearing or purging fails. + pub(crate) fn retry_stuck_unload_cleanup( + &self, + id: &[u8; 32], + ) -> std::result::Result { + self.retry_stuck_unload_cleanup_inner(id, true) + } + + /// Load-path form of [`Self::retry_stuck_unload_cleanup`]. The forgotten + /// marker remains in place across the later network fetch and persistence; + /// [`Self::finish_identity_load_after_persist`] clears it only after the + /// replacement identity is durably stored. + pub(crate) fn prepare_stuck_unload_cleanup_for_reload( + &self, + id: &[u8; 32], + ) -> std::result::Result { + let identifier = Identifier::from(*id); + if !self.is_identity_forgotten(&identifier)? + || self.local_identity_ids()?.contains(&identifier) + { + return Ok(false); + } + + // This also handles a marker-only unload residue whose vault/blob tail + // succeeded after an earlier sidecar failure. Cleanup is idempotent, and + // the marker deliberately remains until the fresh load is persisted. + self.cleanup_identity_after_index_removal(&identifier)?; + Ok(true) + } + + fn retry_stuck_unload_cleanup_inner( + &self, + id: &[u8; 32], + clear_forgotten_marker: bool, + ) -> std::result::Result { + let identifier = Identifier::from(*id); + if !self.is_identity_forgotten(&identifier)? + || !self.has_local_qualified_identity(&identifier)? + || self.local_identity_ids()?.contains(&identifier) + { + return Ok(false); + } + + self.cleanup_identity_after_index_removal(&identifier)?; + if clear_forgotten_marker { + self.db + .clear_forgotten_identity(self.network, &identifier) + .map_err(|source| TaskError::ForgottenIdentityStorage { source })?; + } + Ok(true) + } + + /// Remove identity-scoped residue for a forgotten identity whose recovery + /// blob is already gone. Vault cleanup already completed before that blob + /// could be purged; this finishes best-effort sidecar and metadata cleanup. + pub(crate) fn purge_forgotten_identity_residue( + &self, + identifier: &Identifier, + ) -> std::result::Result<(), TaskError> { + self.cleanup_identity_after_index_removal(identifier) + } + + /// Idempotent cleanup tail shared by normal deletion, ghost recovery, and + /// marker-only full-wipe recovery. The blob is retained whenever its vault + /// inventory could not be cleared. + fn cleanup_identity_after_index_removal( + &self, + identifier: &Identifier, + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let backend = self.wallet_backend()?; + let id = identifier.to_buffer(); + let mut cleanup_error = None; + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_owner_overlays(identifier), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_identity_timestamps(identifier), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.dashpay_clear_identity_addr_map(identifier), + ); + keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + backend.identity_meta().delete(self.network, &id), + ); + match self.clear_identity_vault_keys(&kv, &id) { + Ok(()) => keep_first_unload_cleanup_error( + &mut cleanup_error, + *identifier, + purge_identity_scope(&kv, &id), + ), + Err(error) => { + keep_first_unload_cleanup_error(&mut cleanup_error, *identifier, Err(error)) + } + } + cleanup_error.map_or(Ok(()), Err) } fn delete_local_qualified_identity_inner( @@ -994,15 +1099,44 @@ impl AppContext { identifier: &Identifier, remember_unload: bool, ) -> std::result::Result<(), TaskError> { + let load_guard = self.begin_identity_cleanup_claim(identifier)?; + self.delete_local_qualified_identity_with_claim(identifier, remember_unload)?; + load_guard.loaded(); + Ok(()) + } + + /// Full-wipe deletion that returns its exclusive identity claim so the + /// caller can retain it through marker retirement and wipe completion. + pub(crate) fn delete_local_qualified_identity_retaining_claim( + &self, + identifier: &Identifier, + ) -> std::result::Result + { + let load_guard = self.begin_identity_cleanup_claim(identifier)?; + self.delete_local_qualified_identity_with_claim(identifier, false)?; + Ok(load_guard) + } + + fn begin_identity_cleanup_claim( + &self, + identifier: &Identifier, + ) -> std::result::Result + { // The load registry provides the existing per-identity exclusive claim. - let load_guard = - self.begin_identity_load(*identifier, None) - .map_err(|error| match error { - TaskError::IdentityLoadInProgress { identity_id } => { - TaskError::IdentityBusyWithLoad { identity_id } - } - other => other, - })?; + self.begin_identity_load(*identifier, None) + .map_err(|error| match error { + TaskError::IdentityLoadInProgress { identity_id } => { + TaskError::IdentityBusyWithLoad { identity_id } + } + other => other, + }) + } + + fn delete_local_qualified_identity_with_claim( + &self, + identifier: &Identifier, + remember_unload: bool, + ) -> std::result::Result<(), TaskError> { let _migration_guard = self .migration_run .try_lock() @@ -1011,7 +1145,6 @@ impl AppContext { return Err(TaskError::WalletStorageNotReady); } let kv = self.det_kv()?; - let backend = self.wallet_backend()?; let id = identifier.to_buffer(); crate::backend_task::migration::finish_unwire::record_identity_deletion(self, id).map_err( |source| TaskError::IdentityDeletionMigrationRecord { @@ -1043,45 +1176,7 @@ impl AppContext { } return Err(error); } - let mut cleanup_error = None; - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - backend.dashpay_clear_owner_overlays(identifier), - ); - // Conversation/payment timestamps that are not keyed by this identity - // may be shared; full-wallet teardown is the safe reclamation boundary. - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - backend.dashpay_clear_identity_timestamps(identifier), - ); - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - backend.dashpay_clear_identity_addr_map(identifier), - ); - keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - backend.identity_meta().delete(self.network, &id), - ); - match self.clear_identity_vault_keys(&kv, &id) { - Ok(()) => keep_first_unload_cleanup_error( - &mut cleanup_error, - *identifier, - purge_identity_scope(&kv, &id), - ), - // The identity blob inventories its vault labels. Keep it when the - // clear fails so a later retry can finish deleting those keys. - Err(error) => { - keep_first_unload_cleanup_error(&mut cleanup_error, *identifier, Err(error)) - } - } - if let Some(error) = cleanup_error { - return Err(error); - } - load_guard.loaded(); + self.cleanup_identity_after_index_removal(identifier)?; Ok(()) } @@ -1370,6 +1465,7 @@ impl AppContext { #[cfg(test)] mod tests { use super::*; + use crate::backend_task::BackendTaskSuccessResult; use crate::wallet_backend::kv_test_support::InMemoryKv; use DetKv; use std::io::Write; @@ -2536,7 +2632,7 @@ mod tests { .execute_batch(&trigger_sql) .expect("install owner-overlay delete trigger"); - match ctx.delete_local_qualified_identity(&target_id) { + match ctx.unload_local_qualified_identity(&target_id) { Err(TaskError::IdentityUnloadCleanupFailed { identity_id, source, @@ -2583,10 +2679,34 @@ mod tests { .is_none(), "target vault keys must be removed despite the cleanup failure" ); + assert!( + ctx.is_identity_forgotten(&target_id) + .expect("read marker after partial cleanup"), + "the partial unload must remain discoverable after its blob is purged" + ); + assert!( + !ctx.has_local_qualified_identity(&target_id) + .expect("read blob after partial cleanup"), + "vault cleanup success allows the recovery blob to be purged" + ); fault_connection .execute_batch(&format!("DROP TRIGGER {trigger_name};")) .expect("remove owner-overlay delete trigger"); + ctx.clear_network_database() + .await + .expect("full wipe must retry marker-only identity residue"); + assert!( + kv.get::(DetScope::Identity(&target_buf), overlay_key) + .expect("read owner overlay after full wipe") + .is_none(), + "the full wipe must remove owner residue even when no blob remains" + ); + assert!( + !ctx.is_identity_forgotten(&target_id) + .expect("read marker after full wipe"), + "the marker must clear only after its residue cleanup succeeds" + ); backend.shutdown().await; } @@ -2735,10 +2855,10 @@ mod tests { kv.put(DetScope::Identity(&id_buf), IDENTITY_KEY, &stored("User")) .expect("corrupt the stored blob"); - assert!( - ctx.delete_local_qualified_identity(&target_id).is_err(), - "the corrupted blob must surface as an error" - ); + assert!(matches!( + ctx.unload_local_qualified_identity(&target_id), + Err(TaskError::IdentityUnloadCleanupFailed { .. }) + )); assert!( !ctx.local_identity_ids() .expect("read index") @@ -2751,17 +2871,37 @@ mod tests { .is_some(), "a vault-clear failure must retain the only on-disk inventory of key labels" ); + assert!( + ctx.is_identity_forgotten(&target_id) + .expect("read forgotten marker"), + "the interrupted unload must remain discoverable for retry" + ); + assert!(matches!( + ctx.retry_stuck_unload_cleanup(&id_buf), + Err(TaskError::IdentityUnloadCleanupFailed { .. }) + )); + assert!( + ctx.has_local_qualified_identity(&target_id) + .expect("read retained identity after failed retry"), + "a failed retry must preserve the identity blob" + ); + assert!( + ctx.is_identity_forgotten(&target_id) + .expect("read retained marker after failed retry"), + "a failed retry must preserve the forgotten marker" + ); - // Repair the transiently unreadable inventory and retry by the known - // identity id, as the unload caller can after the reported failure. kv.put( DetScope::Identity(&id_buf), IDENTITY_KEY, &stored_before_fault, ) .expect("restore stored identity"); - ctx.delete_local_qualified_identity(&target_id) - .expect("retry identity deletion"); + assert!( + ctx.retry_stuck_unload_cleanup(&id_buf) + .expect("retry repaired cleanup"), + "repairing the inventory must let the retry finish" + ); assert!( target_vault @@ -2770,6 +2910,295 @@ mod tests { .is_none(), "the retained inventory must let a retry delete the vault key" ); + assert!( + !ctx.has_local_qualified_identity(&target_id) + .expect("read identity after successful retry"), + "a successful retry must purge the retained blob" + ); + assert!( + !ctx.is_identity_forgotten(&target_id) + .expect("read marker after successful retry"), + "a successful retry must clear the forgotten marker" + ); + + backend.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn stuck_unload_retry_ignores_non_ghost_states() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let stored_id = Identifier::from([0xA4; 32]); + let stored_identity = qi_with_id_plaintext_and_derived(stored_id, [0xA5; 32], [0xA6; 32]); + ctx.insert_local_qualified_identity(&stored_identity, &None) + .expect("insert non-forgotten identity"); + assert!( + !ctx.retry_stuck_unload_cleanup(&stored_id.to_buffer()) + .expect("check non-forgotten identity"), + "an identity that was never forgotten is not a cleanup ghost" + ); + assert!( + ctx.has_local_qualified_identity(&stored_id) + .expect("read non-forgotten identity"), + "the non-forgotten identity must remain stored" + ); + + let purged_id = Identifier::from([0xA7; 32]); + ctx.db() + .record_forgotten_identity(Network::Testnet, &purged_id) + .expect("record marker without a blob"); + assert!( + !ctx.retry_stuck_unload_cleanup(&purged_id.to_buffer()) + .expect("check marker without blob"), + "a forgotten identity with no residual blob needs no retry" + ); + assert!( + ctx.is_identity_forgotten(&purged_id) + .expect("read marker without blob"), + "normal marker lifecycle remains the caller's responsibility" + ); + + backend.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reject_if_exists_retries_a_repaired_unload_ghost() { + use crate::app::TaskResult; + use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; + use crate::context::test_support::test_app_context; + use crate::model::secret::Secret; + use crate::utils::egui_mpsc::SenderAsync; + use dash_sdk::SdkBuilder; + use dash_sdk::dpp::platform_value::Value; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; + use dash_sdk::platform::{DocumentQuery, Identifier, Identity}; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let target_id = Identifier::from([0xA8; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0xA9; 32], [0xAA; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + let id_buf = target_id.to_buffer(); + let kv = ctx.det_kv().expect("det kv"); + let stored_before_fault = kv + .get::(DetScope::Identity(&id_buf), IDENTITY_KEY) + .expect("read stored identity") + .expect("stored identity exists"); + kv.put(DetScope::Identity(&id_buf), IDENTITY_KEY, &stored("User")) + .expect("corrupt the stored blob"); + assert!(matches!( + ctx.unload_local_qualified_identity(&target_id), + Err(TaskError::IdentityUnloadCleanupFailed { .. }) + )); + let failed_wipe = ctx.clear_network_database().await; + assert!( + matches!( + failed_wipe, + Err(TaskError::WalletDataClearIncomplete { .. }) + ), + "an unreadable ghost must make the full wipe report incomplete" + ); + assert!( + ctx.has_local_qualified_identity(&target_id) + .expect("read retained ghost after failed wipe"), + "a failed full-wipe retry must preserve the ghost blob" + ); + assert!( + ctx.is_identity_forgotten(&target_id) + .expect("read retained marker after failed wipe"), + "a failed full-wipe retry must preserve the forgotten marker" + ); + kv.put( + DetScope::Identity(&id_buf), + IDENTITY_KEY, + &stored_before_fault, + ) + .expect("restore stored identity"); + + let input = IdentityInputToLoad { + identity_id_input: target_id.to_string(Encoding::Hex), + identity_type: IdentityType::User, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::RejectIfExists, + load_token: None, + }; + let (task_tx, _task_rx) = tokio::sync::mpsc::channel::(32); + let task_sender = SenderAsync::new(task_tx, ctx.egui_ctx().clone()); + let mut missing_sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("build pinned mock SDK"); + missing_sdk + .mock() + .expect_fetch(target_id, None::) + .await + .expect("mock missing identity fetch"); + let failed_result = ctx + .run_identity_task( + IdentityTask::LoadIdentity(input.clone()), + &missing_sdk, + task_sender.clone(), + ) + .await; + assert!( + matches!(failed_result, Err(TaskError::IdentityNotFound)), + "a missing network identity must fail the reload: {failed_result:?}" + ); + assert!( + ctx.is_identity_forgotten(&target_id) + .expect("read marker after failed reload"), + "a failed reload must preserve the user's unload marker" + ); + assert!( + !ctx.has_local_qualified_identity(&target_id) + .expect("read slot after failed reload"), + "the repaired ghost blob should remain purged" + ); + + let mut sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("build pinned mock SDK"); + sdk.mock() + .expect_fetch(target_id, Some(target.identity.clone())) + .await + .expect("mock identity fetch"); + let dpns_query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: ctx.dpns_contract.clone(), + document_type_name: "domain".to_string(), + where_clauses: vec![WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(target_id.into()), + }], + group_by: Vec::new(), + having: Vec::new(), + order_by_clauses: vec![], + limit: 100, + start: None, + }; + sdk.mock() + .expect_fetch_many( + dpns_query, + Some(dash_sdk::query_types::Documents::default()), + ) + .await + .expect("mock DPNS fetch"); + let result = ctx + .run_identity_task(IdentityTask::LoadIdentity(input), &sdk, task_sender) + .await; + + assert!( + matches!( + result, + Ok(BackendTaskSuccessResult::LoadedIdentity(ref identity)) + if identity.identity.id() == target_id + ), + "a repaired ghost must load successfully: {result:?}" + ); + assert!( + ctx.has_local_qualified_identity(&target_id) + .expect("read identity after retry"), + "the fresh identity must be stored after the ghost is purged" + ); + assert!( + !ctx.is_identity_forgotten(&target_id) + .expect("read marker after retry"), + "the fresh load must clear the recovered ghost marker" + ); + + backend.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn clear_network_database_purges_a_repaired_unload_ghost() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let target_id = Identifier::from([0xAB; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0xAC; 32], [0xAD; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + let id_buf = target_id.to_buffer(); + let target_vault = IdentityKeyView::new(backend.secret_store(), id_buf); + let kv = ctx.det_kv().expect("det kv"); + let stored_before_fault = kv + .get::(DetScope::Identity(&id_buf), IDENTITY_KEY) + .expect("read stored identity") + .expect("stored identity exists"); + kv.put(DetScope::Identity(&id_buf), IDENTITY_KEY, &stored("User")) + .expect("corrupt the stored blob"); + assert!(matches!( + ctx.unload_local_qualified_identity(&target_id), + Err(TaskError::IdentityUnloadCleanupFailed { .. }) + )); + kv.put( + DetScope::Identity(&id_buf), + IDENTITY_KEY, + &stored_before_fault, + ) + .expect("restore stored identity"); + + ctx.clear_network_database() + .await + .expect("full wipe must recover and purge the ghost"); + + assert!( + !ctx.has_local_qualified_identity(&target_id) + .expect("read identity after full wipe"), + "the full wipe must purge the ghost blob" + ); + assert!( + !ctx.is_identity_forgotten(&target_id) + .expect("read marker after full wipe"), + "the full wipe must clear the recovered ghost marker" + ); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read vault after full wipe") + .is_none(), + "the full wipe must remove the ghost's vault key" + ); backend.shutdown().await; } diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index 3bb100185..8bfee972f 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -74,16 +74,172 @@ impl AppContext { failures.push(TaskError::DashpaySidecarStorage { source }); } } - if let Err(error) = self.clear_all_forgotten_identities() { - tracing::warn!(error = ?error, "Forgotten identity marker clear failed"); - failures.push(error); + // Hold successful ghost cleanup claims until the whole wipe finishes. + // Otherwise a concurrent explicit load could repopulate the just-purged + // slot after the index sweep and make a reported-success wipe incomplete. + let mut successful_forgotten_cleanup_guards = Vec::new(); + let mut failed_forgotten_cleanup_guards = Vec::new(); + let mut forgotten_marker_clear_candidates = Vec::new(); + let mut forgotten_indexed_identities = Vec::new(); + match self.db.list_forgotten_identities(self.network) { + Ok(forgotten_identities) => { + for identity_id in forgotten_identities { + let load_guard = self + .begin_identity_load(identity_id, None) + .map_err(|error| match error { + TaskError::IdentityLoadInProgress { identity_id } => { + TaskError::IdentityBusyWithLoad { identity_id } + } + other => other, + }); + let (retry_result, load_guard) = match load_guard { + Ok(load_guard) => { + let retry_result = match self.migration_run.try_lock() { + Ok(_migration_guard) + if self.migration_status().state().is_in_progress() => + { + Err(TaskError::WalletStorageNotReady) + } + Ok(_migration_guard) => { + self.retry_stuck_unload_cleanup(&identity_id.to_buffer()) + } + Err(_) => Err(TaskError::WalletStorageNotReady), + }; + (retry_result, Some(load_guard)) + } + Err(error) => (Err(error), None), + }; + match retry_result { + Ok(true) => { + if let Some(load_guard) = load_guard { + successful_forgotten_cleanup_guards.push(load_guard); + } + } + Ok(false) => { + let Some(load_guard) = load_guard else { + unreachable!("a successful retry check owns its load claim"); + }; + match self.local_identity_ids() { + Ok(indexed) if indexed.contains(&identity_id) => { + // The normal indexed wipe below owns this + // identity and acquires its own claim. + forgotten_indexed_identities.push(identity_id); + } + Ok(_) => { + let residue_result = match self.migration_run.try_lock() { + Ok(_migration_guard) + if self.migration_status().state().is_in_progress() => + { + Err(TaskError::WalletStorageNotReady) + } + Ok(_migration_guard) => { + self.purge_forgotten_identity_residue(&identity_id) + } + Err(_) => Err(TaskError::WalletStorageNotReady), + }; + match residue_result { + Ok(()) => { + forgotten_marker_clear_candidates.push(identity_id); + successful_forgotten_cleanup_guards.push(load_guard); + } + Err(error) => { + failed_forgotten_cleanup_guards.push(load_guard); + tracing::warn!( + identity_id = %identity_id, + error = ?error, + "Forgotten identity residue cleanup failed during full wipe" + ); + failures.push(error); + } + } + } + Err(error) => { + failed_forgotten_cleanup_guards.push(load_guard); + tracing::warn!( + identity_id = %identity_id, + error = ?error, + "Identity index check failed during full wipe" + ); + failures.push(error); + } + } + } + Err(error) => { + if let Some(load_guard) = load_guard { + failed_forgotten_cleanup_guards.push(load_guard); + } + // Keep the marker so a later load or full wipe can retry + // cleanup using the residual blob's vault-key inventory. + tracing::warn!( + identity_id = %identity_id, + error = ?error, + "Forgotten identity cleanup retry failed during full wipe" + ); + failures.push(error); + } + } + } + } + Err(source) => { + let error = TaskError::ForgottenIdentityStorage { source }; + tracing::warn!(error = ?error, "Forgotten identity listing failed during full wipe"); + failures.push(error); + } } match self.local_identity_ids() { Ok(owners) => { + for identity_id in &forgotten_indexed_identities { + if !owners.contains(identity_id) { + // The identity changed between the guarded forgotten + // classification and this indexed snapshot. Fail closed + // and keep its marker; the next wipe can classify the + // resulting state without a handoff gap. + tracing::warn!( + identity_id = %identity_id, + "Forgotten identity changed during full-wipe handoff" + ); + failures.push(TaskError::WalletStorageNotReady); + } + } for owner in owners { // Wipe each identity's vault keys and det:identity:* records too — // Tier-1 keyless identity keys (incl. masternode voting/owner/payout) // are plaintext-recoverable, so a full wipe must remove them as well. + if forgotten_indexed_identities.contains(&owner) { + let mut attempts_remaining = IDENTITY_WIPE_ATTEMPTS; + let deletion_result = loop { + match self.delete_local_qualified_identity_retaining_claim(&owner) { + Err(TaskError::IdentityBusyWithLoad { .. }) + if attempts_remaining > 1 => + { + attempts_remaining -= 1; + tokio::time::sleep(IDENTITY_WIPE_RETRY_DELAY).await; + } + result => break result, + } + }; + match deletion_result { + Ok(load_guard) => { + forgotten_marker_clear_candidates.push(owner); + successful_forgotten_cleanup_guards.push(load_guard); + } + Err(error) => { + tracing::warn!( + owner = %owner, + "Identity private-key wipe failed during clear: {error:?}" + ); + let underlying_error = match error { + TaskError::IdentityUnloadCleanupFailed { source, .. } => { + *source + } + other => other, + }; + failures.push(underlying_error); + } + } + continue; + } + let mut attempts_remaining = IDENTITY_WIPE_ATTEMPTS; let deletion_result = loop { match self.delete_local_qualified_identity(&owner) { @@ -96,16 +252,19 @@ impl AppContext { result => break result, } }; - if let Err(e) = deletion_result { - tracing::warn!( - owner = %owner, - "Identity private-key wipe failed during clear: {e:?}" - ); - let underlying_error = match e { - TaskError::IdentityUnloadCleanupFailed { source, .. } => *source, - other => other, - }; - failures.push(underlying_error); + match deletion_result { + Ok(()) => {} + Err(e) => { + tracing::warn!( + owner = %owner, + "Identity private-key wipe failed during clear: {e:?}" + ); + let underlying_error = match e { + TaskError::IdentityUnloadCleanupFailed { source, .. } => *source, + other => other, + }; + failures.push(underlying_error); + } } } } @@ -117,6 +276,19 @@ impl AppContext { failures.push(e); } } + // Retire only markers captured by this sweep whose attributable cleanup + // completed. A blanket clear could erase a recovery marker created by a + // concurrent unload after the listing snapshot. + for identity_id in forgotten_marker_clear_candidates { + if let Err(error) = self.clear_forgotten_identity_after_explicit_load(&identity_id) { + tracing::warn!( + identity_id = %identity_id, + error = ?error, + "Forgotten identity marker clear failed during full wipe" + ); + failures.push(error); + } + } // Reset the upstream shielded coordinator (quiesces its sync loop and // empties the per-network store) and unlink DET's two retired legacy @@ -139,6 +311,11 @@ impl AppContext { self.has_wallet.store(false, Ordering::Relaxed); + for load_guard in successful_forgotten_cleanup_guards { + load_guard.loaded(); + } + drop(failed_forgotten_cleanup_guards); + // Any secret-bearing delete that failed above means data may survive on // disk, so never report a clean wipe. The in-memory maps are still // cleared; the typed error tells the user to restart and retry. diff --git a/src/database/forgotten_identities.rs b/src/database/forgotten_identities.rs index f331be583..da4254294 100644 --- a/src/database/forgotten_identities.rs +++ b/src/database/forgotten_identities.rs @@ -46,14 +46,28 @@ impl Database { Ok(()) } - /// Clear every deliberately unloaded identity marker on one network. - pub(crate) fn clear_all_forgotten_identities(&self, network: Network) -> rusqlite::Result<()> { - self.execute( - "DELETE FROM forgotten_identities + /// List every identity deliberately unloaded on one network. + pub(crate) fn list_forgotten_identities( + &self, + network: Network, + ) -> rusqlite::Result> { + let conn = self.locked_conn(); + let mut statement = conn.prepare( + "SELECT identity_id FROM forgotten_identities WHERE network = ?1", - params![network.to_string()], )?; - Ok(()) + let rows = statement.query_map(params![network.to_string()], |row| { + let bytes: Vec = row.get(0)?; + let id: [u8; 32] = bytes.as_slice().try_into().map_err(|source| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Blob, + Box::new(source), + ) + })?; + Ok(Identifier::from(id)) + })?; + rows.collect() } /// Whether an identity is deliberately unloaded on one network. @@ -103,4 +117,31 @@ mod tests { .expect("read cleared marker") ); } + + #[test] + fn forgotten_identity_listing_is_network_scoped() { + let db = create_test_database().expect("create database"); + let testnet_ids = [Identifier::from([0x41; 32]), Identifier::from([0x42; 32])]; + let mainnet_id = Identifier::from([0x43; 32]); + + for identity_id in &testnet_ids { + db.record_forgotten_identity(Network::Testnet, identity_id) + .expect("record testnet marker"); + } + db.record_forgotten_identity(Network::Mainnet, &mainnet_id) + .expect("record mainnet marker"); + + let mut listed = db + .list_forgotten_identities(Network::Testnet) + .expect("list testnet markers"); + listed.sort_unstable(); + let mut expected = testnet_ids.to_vec(); + expected.sort_unstable(); + + assert_eq!(listed, expected); + assert!( + !listed.contains(&mainnet_id), + "identities from another network must not be returned" + ); + } } diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 8ffa7d718..03773826d 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -542,6 +542,11 @@ impl ScreenLike for IdentityHubScreen { MessageType::Success, ); } + BackendTaskSuccessResult::RemovedIdentities { identity_ids, .. } => { + for identity_id in identity_ids { + self.profile_cache.remove_identity(identity_id); + } + } // Populate the Received/Sent request caches so the Contacts tab // can render real RequestCard rows instead of hardcoded empties. // The result arrives from LoadContactRequests, @@ -980,6 +985,42 @@ mod tests { ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn removed_identities_invalidate_every_profile_cache_entry() { + let (_temp_dir, context) = wired_test_context().await; + let first_id = seed_user_identity(&context, 4); + let second_id = seed_user_identity(&context, 5); + let first = context + .get_local_qualified_identity(&first_id) + .expect("read first identity") + .expect("first identity exists"); + let second = context + .get_local_qualified_identity(&second_id) + .expect("read second identity") + .expect("second identity exists"); + let mut screen = IdentityHubScreen::new(&context); + + for identity_id in [first_id, second_id] { + screen.profile_cache.record_saved( + identity_id, + crate::ui::identity::profile_cache::ProfileFields { + display_name: "Cached name".to_string(), + ..Default::default() + }, + ); + } + + screen.display_task_result(BackendTaskSuccessResult::RemovedIdentities { + identity_ids: vec![first_id, second_id], + primary_cleanup_failed: false, + associated_cleanup_failed: false, + associated_removal_failed: false, + }); + + assert!(screen.profile_cache.get_or_request(&first).is_none()); + assert!(screen.profile_cache.get_or_request(&second).is_none()); + } + #[test] fn a_result_for_the_selected_identity_applies() { assert!(applies_to_selected_identity(Some(id(1)), &id(1))); From 4652cb051949e7f41e9de98acb8ea3734d45a216 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:43:04 +0000 Subject: [PATCH 26/46] fix(wallet): resolve forgotten-identity load guards before fallible cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clear_network_database deferred resolving successful ghost-cleanup IdentityLoadGuards past cleanup_legacy_shielded_files()'s `?` — an early return there would drop the guards unresolved, and IdentityLoadGuard::drop records Failed, mislabeling an identity whose ghost cleanup durably succeeded this pass. Move guard resolution right after the last per-identity step (marker retirement), before any later fallible work. Found by independent QA verification of commit 6f0abf7c6 (QA-001/QA-002 fix). Verified: clear_network_database test suite (8/8) passes, scoped clippy clean, fmt clean. Co-Authored-By: Claude Sonnet 5 --- src/context/wallet_lifecycle/spv.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index 8bfee972f..f10d18126 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -290,6 +290,18 @@ impl AppContext { } } + // Resolve every forgotten-identity load claim now, right after the last + // step that still touches per-identity state (the marker retirement + // above). Everything from here on (`?`-fallible shielded cleanup + // included) must not be able to make these claims report `Failed` by + // early-returning past an unresolved guard — an identity whose ghost + // cleanup durably succeeded this pass must record `Loaded`, regardless + // of what happens to unrelated state afterward. + for load_guard in successful_forgotten_cleanup_guards { + load_guard.loaded(); + } + drop(failed_forgotten_cleanup_guards); + // Reset the upstream shielded coordinator (quiesces its sync loop and // empties the per-network store) and unlink DET's two retired legacy // shielded files. The legacy-file unlinks are synchronous and scoped @@ -311,11 +323,6 @@ impl AppContext { self.has_wallet.store(false, Ordering::Relaxed); - for load_guard in successful_forgotten_cleanup_guards { - load_guard.loaded(); - } - drop(failed_forgotten_cleanup_guards); - // Any secret-bearing delete that failed above means data may survive on // disk, so never report a clean wipe. The in-memory maps are still // cleared; the typed error tells the user to restart and retry. From 2acf8edc8f7cab0add25ce2f8f6e1f7942ba39ff Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:45:01 +0000 Subject: [PATCH 27/46] docs: reflect QA-001/QA-002 closure in CHANGELOG and PR body The SEC-001 "known limitation" (retained records had no recovery path) is closed by 6f0abf7c6/4652cb051: reload now finishes the deferred cleanup instead of rejecting as already-loaded, and the full-wipe sweep reaches retained records too. Update the CHANGELOG's Fixed bullet and the PR body (removed the stale known-limitation callout, added Fix #5). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7caa37ccd..502c1c44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). when marker cleanup leaves recoverable residue, associated voter removal outcomes are reported accurately, partial cleanup failures still reconcile the app's active identity state, and late DashPay profile responses can no - longer restore stale profile data. + longer restore stale profile data. Reloading a masternode or evonode + identity that was left in a recoverable state by an earlier cleanup failure + now finishes that cleanup and completes the load, instead of being rejected + as already loaded; "delete all local data" now reaches those same + recoverable identities too. - **Wallet rename consistency**: renaming a wallet no longer overwrites other saved wallet details when metadata cannot be read. Overlapping renames and From 2bf818268a6acacfc8927d7501e699274cdf86e7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:44:19 +0000 Subject: [PATCH 28/46] fix(identity): clean unload ghosts before every reload path (CMT-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prepare_stuck_unload_cleanup_for_reload (added by the QA-001 fix, commit 6f0abf7c6) was wired into exactly one call site: load_identity's RejectIfExists branch. Three other paths could still silently overwrite a repaired-unload ghost's blob before its old vault key was cleared, permanently stranding that key (clear_identity_vault_keys derives deletable labels solely from the on-disk blob): - load_identity: Overwrite/MergeIntoExisting modes (hoisted the call out of the RejectIfExists-only gate). - load_identity_from_wallet::load_user_identity_from_wallet. - load_identity_by_dpns_name::load_identity_by_dpns_name (extracted into persist_identity_loaded_by_dpns_name for a single call site). - discover_identities::persist_discovered_identity's explicit-reload path (found by independent QA verification of the first three sites, confirmed via an actual failing-test reproduction before the fix). Added a shared install_repaired_unload_ghost_for_test fixture and regression coverage for all four sites, each asserting the OLD vault key is actually cleared before the replacement blob is written — not just that the reload succeeds. Verified independently: cargo fmt clean, scoped clippy clean (forced non-cached re-run), full identity_db/load_identity/ load_identity_from_wallet/load_identity_by_dpns_name/discover_identities test modules pass with the new test names confirmed in the raw log. Implementation by Codex Sol (gpt-5.6-sol, high effort) across two dispatches to the same worktree; independently verified (including an adversarial pass that found the fourth site) and committed by the coordinator — Codex's own in-sandbox commit was blocked by read-only worktree metadata both times. Co-Authored-By: Claude Sonnet 5 --- .../identity/discover_identities.rs | 58 ++++++- src/backend_task/identity/load_identity.rs | 146 +++++++++++++++++- .../identity/load_identity_by_dpns_name.rs | 89 +++++++++++ .../identity/load_identity_from_wallet.rs | 144 +++++++++++++++++ src/context/identity_db.rs | 106 +++++++++++++ 5 files changed, 535 insertions(+), 8 deletions(-) diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index 6587d7e0c..d6a1a5eaf 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -333,6 +333,7 @@ impl AppContext { return Ok(None); } + self.prepare_stuck_unload_cleanup_for_reload(&identity_id.to_buffer())?; match self.get_identity_by_id(&identity_id)? { Some(existing) => { // Carry DET-only metadata onto the refreshed identity, then @@ -517,8 +518,11 @@ mod tests { use crate::app::TaskResult; use crate::context::identity_load_registry::IdentityLoadPhase; use crate::context::test_support::test_app_context; - use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + use crate::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, + }; use crate::utils::egui_mpsc::SenderAsync; + use crate::wallet_backend::IdentityKeyView; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::{Identifier, Identity}; @@ -649,6 +653,58 @@ mod tests { backend.shutdown().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn explicit_discovery_clears_repaired_unload_ghost_key() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let wallet = Arc::new(RwLock::new( + Wallet::new_from_seed([0x66; 64], Network::Testnet, None, None).expect("build wallet"), + )); + let identity_index = 6; + let wallet_seed_hash = wallet.read().expect("read wallet").seed_hash(); + ctx.wallets() + .write() + .expect("write wallets") + .insert(wallet_seed_hash, Arc::clone(&wallet)); + let identity_id = Identifier::from([0x67; 32]); + let replacement = wallet_derived_identity(identity_id, &wallet, identity_index); + let old_key_id = + ctx.install_repaired_unload_ghost_for_test(replacement.identity.clone(), [0x68; 32]); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, old_key_id,) + .expect("read old vault key before reload") + .is_some(), + "precondition: the interrupted unload retains its old vault key", + ); + + let load_guard = ctx + .persist_discovered_identity(replacement, wallet_seed_hash, identity_index, true) + .expect("persist explicit discovery") + .expect("explicit discovery must restore the identity"); + ctx.finish_identity_load_after_persist(&identity_id, load_guard); + + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, old_key_id,) + .expect("read old vault key after reload") + .is_none(), + "the old vault key must be cleared before explicit discovery writes its blob", + ); + assert!( + !ctx.is_identity_forgotten(&identity_id) + .expect("read marker after reload"), + "a successful explicit discovery must retire the forgotten marker", + ); + + backend.shutdown().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unload_during_discovery_persist_does_not_resurrect_wallet_cache() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 033f1de1e..4b7568002 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -181,15 +181,15 @@ impl AppContext { // tier are never silently overwritten. Checked here, at the storage // layer, so every `RejectIfExists` caller is guarded uniformly. let mut existing_stored = self.get_local_qualified_identity(&identity_id)?; - if load_mode == IdentityLoadMode::RejectIfExists { - if self.prepare_stuck_unload_cleanup_for_reload(&identity_id.to_buffer())? { - existing_stored = None; - } else if existing_stored + if self.prepare_stuck_unload_cleanup_for_reload(&identity_id.to_buffer())? { + existing_stored = None; + } + if load_mode == IdentityLoadMode::RejectIfExists + && existing_stored .as_ref() .is_some_and(|identity| !is_bare_placeholder(identity)) - { - return Err(TaskError::DuplicateProTxHash { identity_id }); - } + { + return Err(TaskError::DuplicateProTxHash { identity_id }); } // An in-place merge into a password-protected (Tier-2) node must @@ -1683,4 +1683,136 @@ mod tests { backend.shutdown().await; } + + async fn assert_generic_reload_clears_repaired_unload_ghost( + load_mode: IdentityLoadMode, + id_byte: u8, + ) { + use crate::context::test_support::test_app_context; + use dash_sdk::SdkBuilder; + use dash_sdk::dpp::platform_value::Value; + use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; + use dash_sdk::platform::DocumentQuery; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let identity_id = Identifier::from([id_byte; 32]); + let identity = Identity::create_basic_identity(identity_id, PlatformVersion::latest()) + .expect("identity"); + let old_key_id = + ctx.install_repaired_unload_ghost_for_test(identity.clone(), [id_byte + 1; 32]); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + assert!( + view.get(&M, old_key_id) + .expect("read old vault key before reload") + .is_some(), + "precondition: the interrupted unload retains its old vault key", + ); + + let mut sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("build pinned mock SDK"); + sdk.mock() + .expect_fetch(identity_id, Some(identity)) + .await + .expect("mock identity fetch"); + let dpns_query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: ctx.dpns_contract.clone(), + document_type_name: "domain".to_string(), + where_clauses: vec![WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.into()), + }], + group_by: Vec::new(), + having: Vec::new(), + order_by_clauses: vec![], + limit: 100, + start: None, + }; + sdk.mock() + .expect_fetch_many( + dpns_query, + Some(dash_sdk::query_types::Documents::default()), + ) + .await + .expect("mock DPNS fetch"); + let input = IdentityInputToLoad { + identity_id_input: identity_id.to_string(Encoding::Hex), + identity_type: IdentityType::User, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode, + load_token: None, + }; + + let result = ctx.load_identity(&sdk, input).await; + assert!( + matches!( + result, + Ok(BackendTaskSuccessResult::LoadedIdentity(ref loaded)) + if loaded.identity.id() == identity_id + ), + "the repaired ghost must reload successfully: {result:?}", + ); + let Ok(BackendTaskSuccessResult::LoadedIdentity(loaded)) = &result else { + unreachable!("the successful load shape was asserted above"); + }; + assert!( + !loaded + .private_keys + .private_keys + .contains_key(&(M, old_key_id)), + "the replacement identity must not retain the ghost's stale key metadata", + ); + let persisted = ctx + .get_local_qualified_identity(&identity_id) + .expect("read replacement identity") + .expect("replacement identity is stored"); + assert!( + !persisted + .private_keys + .private_keys + .contains_key(&(M, old_key_id)), + "the replacement blob must not retain the ghost's stale key metadata", + ); + assert!( + view.get(&M, old_key_id) + .expect("read old vault key after reload") + .is_none(), + "the old vault key must be cleared before the replacement blob is written", + ); + assert!( + !ctx.is_identity_forgotten(&identity_id) + .expect("read marker after reload"), + "a successful replacement load must retire the forgotten marker", + ); + + backend.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn overwrite_and_merge_clear_repaired_unload_ghost_keys() { + assert_generic_reload_clears_repaired_unload_ghost(IdentityLoadMode::Overwrite, 0xB1).await; + assert_generic_reload_clears_repaired_unload_ghost( + IdentityLoadMode::MergeIntoExisting, + 0xB3, + ) + .await; + } } diff --git a/src/backend_task/identity/load_identity_by_dpns_name.rs b/src/backend_task/identity/load_identity_by_dpns_name.rs index 205048bd0..dd60ed7ff 100644 --- a/src/backend_task/identity/load_identity_by_dpns_name.rs +++ b/src/backend_task/identity/load_identity_by_dpns_name.rs @@ -7,6 +7,7 @@ use crate::model::qualified_identity::{ use crate::model::wallet::WalletSeedHash; use dash_sdk::Sdk; use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; use dash_sdk::drive::query::{SelectProjection, WhereClause, WhereOperator}; use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identity}; @@ -154,14 +155,102 @@ impl AppContext { status: IdentityStatus::Active, network: self.network, }; + self.persist_identity_loaded_by_dpns_name(qualified_identity, load_guard) + } + + fn persist_identity_loaded_by_dpns_name( + &self, + qualified_identity: QualifiedIdentity, + load_guard: crate::context::identity_load_registry::IdentityLoadGuard, + ) -> Result { + let identity_id = qualified_identity.identity.id(); let wallet_info = qualified_identity .determine_wallet_info() .map_err(|e| TaskError::WalletInfoDeterminationFailed { detail: e })?; // Insert qualified identity into the database + self.prepare_stuck_unload_cleanup_for_reload(&identity_id.to_buffer())?; self.insert_local_qualified_identity(&qualified_identity, &wallet_info)?; self.finish_identity_load_after_persist(&identity_id, load_guard); Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::test_support::test_app_context; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::utils::egui_mpsc::SenderAsync; + use crate::wallet_backend::IdentityKeyView; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn dpns_load_clears_repaired_unload_ghost_key() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let platform_version = PlatformVersion::latest(); + let identity_id = Identifier::from([0xD1; 32]); + let identity = + Identity::create_basic_identity(identity_id, platform_version).expect("identity"); + let old_key_id = ctx.install_repaired_unload_ghost_for_test(identity.clone(), [0xD2; 32]); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, old_key_id,) + .expect("read old vault key before reload") + .is_some(), + "precondition: the interrupted unload retains its old vault key", + ); + + let load_guard = ctx + .begin_identity_load_and_validate_type(IdentityType::User, &identity, None) + .expect("claim replacement load"); + let qualified_identity = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some("alice.dash".to_string()), + private_keys: Default::default(), + dpns_names: Vec::new(), + associated_wallets: Default::default(), + secret_access: Some(backend.secret_access()), + wallet_index: None, + top_ups: Default::default(), + status: IdentityStatus::Active, + network: ctx.network(), + }; + let result = ctx.persist_identity_loaded_by_dpns_name(qualified_identity, load_guard); + assert!( + matches!( + result, + Ok(BackendTaskSuccessResult::LoadedIdentity(ref loaded)) + if loaded.identity.id() == identity_id + ), + "the repaired ghost must reload by DPNS name: {result:?}", + ); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, old_key_id,) + .expect("read old vault key after reload") + .is_none(), + "the old vault key must be cleared before the DPNS load writes its blob", + ); + assert!( + !ctx.is_identity_forgotten(&identity_id) + .expect("read marker after reload"), + "a successful DPNS load must retire the forgotten marker", + ); + + backend.shutdown().await; + } +} diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 5990cc7b8..31272806f 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -252,6 +252,7 @@ impl AppContext { // Carry the user-assigned alias from any existing record so a re-load // refreshes keys/DPNS without wiping DET-only metadata. + self.prepare_stuck_unload_cleanup_for_reload(&identity_id.to_buffer())?; if let Some(existing) = self.get_identity_by_id(&identity_id)? { qualified_identity.alias = existing.alias; self.update_local_qualified_identity(&qualified_identity)?; @@ -301,3 +302,146 @@ impl AppContext { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::test_support::test_app_context; + use crate::model::wallet::Wallet; + use crate::model::wallet::birth_height::WalletOrigin; + use crate::utils::egui_mpsc::SenderAsync; + use crate::wallet_backend::IdentityKeyView; + use dash_sdk::SdkBuilder; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, + }; + use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; + use dash_sdk::dpp::platform_value::BinaryData; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn wallet_load_clears_repaired_unload_ghost_key() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender.clone()) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let seed = [0xC1; 64]; + let identity_index = 4; + let wallet = Wallet::new_from_seed( + seed, + Network::Testnet, + Some("Ghost reload".to_string()), + None, + ) + .expect("build wallet"); + let derived_public_key = wallet + .identity_authentication_ecdsa_public_key_from_seed( + &seed, + Network::Testnet, + identity_index, + 0, + ) + .expect("derive identity authentication key"); + let (wallet_seed_hash, wallet) = ctx + .register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let platform_version = PlatformVersion::latest(); + let identity_id = Identifier::from([0xC2; 32]); + let mut identity_key = IdentityPublicKey::random_key(1, Some(1), platform_version); + identity_key.set_purpose(Purpose::AUTHENTICATION); + identity_key.set_security_level(SecurityLevel::MASTER); + identity_key.set_key_type(KeyType::ECDSA_SECP256K1); + identity_key.set_data(BinaryData::new( + derived_public_key.inner.serialize().to_vec(), + )); + let identity = Identity::new_with_id_and_keys( + identity_id, + BTreeMap::from([(identity_key.id(), identity_key)]), + platform_version, + ) + .expect("build wallet-backed identity"); + let old_key_id = ctx.install_repaired_unload_ghost_for_test(identity.clone(), [0xC3; 32]); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, old_key_id,) + .expect("read old vault key before reload") + .is_some(), + "precondition: the interrupted unload retains its old vault key", + ); + + let mut sdk = SdkBuilder::new_mock() + .with_version(platform_version) + .build() + .expect("build pinned mock SDK"); + let identity_query = NonUniquePublicKeyHashQuery { + key_hash: derived_public_key.pubkey_hash().into(), + after: None, + }; + sdk.mock() + .expect_fetch(identity_query, Some(identity)) + .await + .expect("mock wallet identity fetch"); + let dpns_query = DocumentQuery { + select: SelectProjection::documents(), + data_contract: ctx.dpns_contract.clone(), + document_type_name: "domain".to_string(), + where_clauses: vec![WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.into()), + }], + group_by: Vec::new(), + having: Vec::new(), + order_by_clauses: vec![], + limit: 100, + start: None, + }; + sdk.mock() + .expect_fetch_many( + dpns_query, + Some(dash_sdk::query_types::Documents::default()), + ) + .await + .expect("mock DPNS fetch"); + + let result = ctx + .load_user_identity_from_wallet( + &sdk, + WalletArcRef { + wallet, + seed_hash: wallet_seed_hash, + }, + identity_index, + sender, + ) + .await; + assert!( + matches!( + result, + Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: 1 }) + ), + "the repaired ghost must reload from its wallet: {result:?}", + ); + assert!( + view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, old_key_id,) + .expect("read old vault key after reload") + .is_none(), + "the old vault key must be cleared before the wallet load writes its blob", + ); + assert!( + !ctx.is_identity_forgotten(&identity_id) + .expect("read marker after reload"), + "a successful wallet load must retire the forgotten marker", + ); + + backend.shutdown().await; + } +} diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 9fe45de7f..24db37f65 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1462,6 +1462,97 @@ impl AppContext { } } +#[cfg(test)] +impl AppContext { + /// Install and repair the retained blob for an interrupted unload. + pub(crate) fn install_repaired_unload_ghost_for_test( + &self, + identity: dash_sdk::platform::Identity, + secret: [u8; 32], + ) -> dash_sdk::dpp::identity::KeyID { + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::IdentityPublicKey; + + let identity_id = identity.id(); + let identity_buf = identity_id.to_buffer(); + let key = IdentityPublicKey::random_key(97, Some(97), PlatformVersion::latest()); + let key_id = key.id(); + let mut private_keys = KeyStorage::default(); + private_keys.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::Clear(secret), + ), + ); + let qualified_identity = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some("Interrupted unload".to_string()), + private_keys, + dpns_names: Vec::new(), + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: self.network, + }; + self.insert_local_qualified_identity(&qualified_identity, &None) + .expect("insert identity before faulted unload"); + + let kv = self.det_kv().expect("open identity k/v"); + let stored_before_fault = kv + .get::(DetScope::Identity(&identity_buf), IDENTITY_KEY) + .expect("read stored identity") + .expect("stored identity exists"); + kv.put( + DetScope::Identity(&identity_buf), + IDENTITY_KEY, + &StoredQualifiedIdentity { + qi_bytes: vec![0xAB; 16], + status: IdentityStatus::Active.as_u8(), + identity_type: IdentityType::User.as_tag().to_string(), + wallet_hash: None, + wallet_index: None, + }, + ) + .expect("corrupt retained identity inventory"); + + assert!(matches!( + self.unload_local_qualified_identity(&identity_id), + Err(TaskError::IdentityUnloadCleanupFailed { .. }) + )); + kv.put( + DetScope::Identity(&identity_buf), + IDENTITY_KEY, + &stored_before_fault, + ) + .expect("repair retained identity inventory"); + assert!( + !self + .local_identity_ids() + .expect("read identity index") + .contains(&identity_id), + "precondition: the interrupted unload is not indexed", + ); + assert!( + self.is_identity_forgotten(&identity_id) + .expect("read forgotten marker"), + "precondition: the interrupted unload remains marked", + ); + + key_id + } +} + #[cfg(test)] mod tests { use super::*; @@ -3000,6 +3091,14 @@ mod tests { ctx.insert_local_qualified_identity(&target, &None) .expect("insert target identity"); let id_buf = target_id.to_buffer(); + let target_vault = IdentityKeyView::new(backend.secret_store(), id_buf); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read old key before ghost recovery") + .is_some(), + "precondition: the interrupted unload retains its old vault key", + ); let kv = ctx.det_kv().expect("det kv"); let stored_before_fault = kv .get::(DetScope::Identity(&id_buf), IDENTITY_KEY) @@ -3082,6 +3181,13 @@ mod tests { .expect("read slot after failed reload"), "the repaired ghost blob should remain purged" ); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read old key after ghost recovery") + .is_none(), + "ghost recovery must clear the old vault key before any replacement is written", + ); let mut sdk = SdkBuilder::new_mock() .with_version(PlatformVersion::latest()) From 5f93cd4e1c7248e21d4cae08f65d0908ef16cb8a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:46:09 +0000 Subject: [PATCH 29/46] docs: reflect CMT-001 closure (ghost cleanup on every reload path) CHANGELOG's Fix #4/#5 bullet now covers all five reload paths, not just the masternode Load screen's duplicate-check. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9578e781..9c37e0e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). identity that was left in a recoverable state by an earlier cleanup failure now finishes that cleanup and completes the load, instead of being rejected as already loaded; "delete all local data" now reaches those same - recoverable identities too. + recoverable identities too. That same recovery now runs consistently across + every way an identity can be reloaded — overwriting, merging keys into an + existing record, loading from a wallet, loading by DPNS name, and automatic + wallet discovery — so a leftover key from an earlier interrupted unload is + always cleared before a fresh load replaces it, not just on one path. - **Wallet rename consistency**: renaming a wallet no longer overwrites other saved wallet details when metadata cannot be read. Overlapping renames and From 9d5fa7a29dd359e1669a7147186fca77d3e34235 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek Date: Mon, 27 Jul 2026 15:23:50 +0000 Subject: [PATCH 30/46] fix(identity): hold wipe claims to the end and keep faulted removals findable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-wipe path released each identity's exclusive claim as soon as that identity was deleted, so a concurrent load could persist a fresh blob after the wipe's only index sweep and the wipe still reported success. Ordinary identities were worse off than forgotten-marked ones: they went through the claim-releasing delete, reopening their slot for the rest of the sweep. Every identity now goes through one claim-retaining deletion path, and all claims resolve after the last step that can touch per-identity state. The legacy shielded-file cleanup therefore no longer propagates with `?` — an early return there would drop held claims unresolved and report durably wiped identities as failed loads. The forgotten-identity claim invariant degrades into a recorded failure instead of panicking mid-wipe. A delete that does not remember the unload now writes a best-effort forgotten marker when the cleanup tail fails. The index entry is already gone at that point, so without the marker the identity was reachable by no recovery path while its vault keys survived on disk. User-facing copy follows: Remove on the Identities list and Remove masternode reuse the Identity Hub unload disclosure (the masternode dialog keeps its own voting-identity sentence), that disclosure states what is actually deleted, where the synced data it leaves behind is removed, and that the app records the unload, and the cleanup-failure error names the recovery that exists instead of a retry that cannot work. Also renumbers the new unload user story off the ID already taken by the identity top-up story. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 26 ++++ docs/user-stories.md | 9 +- src/backend_task/error.rs | 6 +- src/context/identity_db.rs | 99 ++++++++++++- src/context/wallet_lifecycle/spv.rs | 111 +++++++------- src/context/wallet_lifecycle/tests.rs | 194 +++++++++++++++++++++++++ src/ui/identities/identities_screen.rs | 50 +++++-- src/ui/identity/settings.rs | 99 ++++++++++--- src/ui/masternodes/detail_screen.rs | 116 +++++++++++++-- 9 files changed, 599 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c37e0e2b..08964d15c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). wallet discovery — so a leftover key from an earlier interrupted unload is always cleared before a fresh load replaces it, not just on one path. +- **"Delete all local data" no longer reports a clean wipe it did not finish**: + the wipe now keeps every identity reserved until the last step is done, so an + identity being loaded in the background cannot be written back to disk after + the wipe has already passed it and still be reported as erased. A failure + while removing retired shielded files no longer aborts the wipe partway or + makes identities that were erased successfully look as though they failed. + +- **A removal interrupted by a cleanup failure stays findable**: if the app + cannot finish clearing an identity's local data, it now records the identity + as unloaded, so loading it again finds the leftovers and finishes the job. + Previously such an identity could disappear from every list while its private + keys remained on the device with nothing able to reach them. The message + shown when this happens names that recovery — load the identity again, then + remove it a second time — instead of suggesting a retry that was impossible. + +- **Removing an identity now says what it really does**: the "Remove" action on + the Identities list and "Remove masternode" on the masternode page show the + same confirmation as Identity Hub → Settings, rather than wording that + suggested the identity was merely untracked. That confirmation is now + accurate too: it names the private keys and app entry that are deleted, + points to the "Clear Database" action in Settings for synced data such as + contacts and payment history that this action does not remove, and discloses + that the app remembers the unload so automatic discovery does not bring the + identity back. Removing a masternode still states that its voting identity + goes with it. + - **Wallet rename consistency**: renaming a wallet no longer overwrites other saved wallet details when metadata cannot be read. Overlapping renames and wallet removals also keep displayed aliases and deleted-wallet metadata diff --git a/docs/user-stories.md b/docs/user-stories.md index e3d7aeb17..810bd799d 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -665,17 +665,20 @@ As a user, I want the identities I loaded before an upgrade — and the keys the - When identities and scheduled votes are both unreadable on the same launch, one banner names both remedies, and acknowledging it retires both reports — neither report can bury the other. - An identity the user deletes after the upgrade stays deleted. The import runs once, so a later launch never restores a removed identity, its alias, or its keys. -### IDN-017: Unload one identity from this device [Implemented] +### IDN-020: Unload one identity from this device [Implemented] **Persona:** Alex, Priya As a user, I want to unload one identity from this device so that I can recover from an incorrect import or stop keeping its private keys locally without removing a shared wallet. -- The Identity Hub asks for confirmation before unloading. If the identity is +- The Identity Hub, the Identities list, and the masternode detail view all ask + for the same confirmation before unloading. If the identity is wallet-derived, it explains that the identity can be loaded again from the wallet's recovery seed. Otherwise, it warns that keys stored only on this device are permanently deleted and require separate recovery information. If scheduled votes are queued, the confirmation states how many will be - cancelled. + cancelled. The confirmation also names the synced data that only a full + database clear removes, and discloses that the app records the unload so + automatic discovery does not bring the identity back. - Unloading removes only the selected identity's local keys, metadata, DashPay overlays, queued scheduled votes, and device record while leaving the Platform identity unchanged. - Other identities on the same wallet and the wallet's recovery seed remain available. diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 39e539a19..595508a43 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -683,8 +683,12 @@ pub enum TaskError { /// Owner-attributable local state could not be fully removed while unloading /// an identity. Cleanup continues after failures, but only the first failure /// is preserved in the nested typed error for logs. + /// + /// The identity is already out of the local index when this is raised, so it + /// is gone from every screen: the only recovery is to load it again, which + /// finishes the deferred cleanup, and then remove it a second time. #[error( - "Some local data for identity {identity_id} could not be fully removed. Wait a moment and try again." + "Some local data for identity {identity_id} could not be fully removed. Load this identity again, then remove it a second time to finish clearing it." )] IdentityUnloadCleanupFailed { identity_id: Identifier, diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 24db37f65..07c383ff3 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1176,7 +1176,24 @@ impl AppContext { } return Err(error); } - self.cleanup_identity_after_index_removal(identifier)?; + if let Err(error) = self.cleanup_identity_after_index_removal(identifier) { + // The index entry is already gone, so without a marker this identity + // is reachable by no recovery path while its vault keys survive. A + // remembered unload wrote its marker above; every other caller gets + // one here. Never mask the cleanup error with a marker-write failure. + if !remember_unload + && let Err(marker_error) = + self.db.record_forgotten_identity(self.network, identifier) + { + tracing::warn!( + identity_id = %identifier, + original_error = ?error, + marker_error = ?marker_error, + "Failed to record safety-net forgotten marker after cleanup failure" + ); + } + return Err(error); + } Ok(()) } @@ -3015,6 +3032,86 @@ mod tests { backend.shutdown().await; } + /// A plain delete records no forgotten marker up front, so a cleanup fault + /// after the index removal would leave the identity reachable by no recovery + /// path — not the index, not the marker sweeps — while its vault keys + /// survive on disk. The delete must write a safety-net marker on that path. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn delete_without_remembered_unload_records_a_safety_net_marker_on_cleanup_failure() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + let target_id = Identifier::from([0xB1; 32]); + let target = qi_with_id_plaintext_and_derived(target_id, [0xB2; 32], [0xB3; 32]); + ctx.insert_local_qualified_identity(&target, &None) + .expect("insert target identity"); + + let id_buf = target_id.to_buffer(); + let target_vault = IdentityKeyView::new(backend.secret_store(), id_buf); + let kv = ctx.det_kv().expect("det kv"); + let stored_before_fault = kv + .get::(DetScope::Identity(&id_buf), IDENTITY_KEY) + .expect("read stored identity") + .expect("stored identity exists"); + // Corrupt the stored blob so the vault-key clear cannot decode the + // inventory of labels and the cleanup tail fails. + kv.put(DetScope::Identity(&id_buf), IDENTITY_KEY, &stored("User")) + .expect("corrupt the stored blob"); + + assert!(matches!( + ctx.delete_local_qualified_identity(&target_id), + Err(TaskError::IdentityUnloadCleanupFailed { .. }) + )); + assert!( + !ctx.local_identity_ids() + .expect("read index") + .contains(&target_id), + "precondition: the identity is already out of the index" + ); + assert!( + ctx.is_identity_forgotten(&target_id) + .expect("read forgotten marker"), + "a faulted delete must leave a marker, or no recovery path can find \ + the identity again" + ); + + kv.put( + DetScope::Identity(&id_buf), + IDENTITY_KEY, + &stored_before_fault, + ) + .expect("restore stored identity"); + assert!( + ctx.retry_stuck_unload_cleanup(&id_buf) + .expect("retry repaired cleanup"), + "the safety-net marker must make the residue recoverable" + ); + assert!( + target_vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 1) + .expect("read target key after retry") + .is_none(), + "the recovered retry must delete the vault key the fault left behind" + ); + assert!( + !ctx.is_identity_forgotten(&target_id) + .expect("read marker after successful retry"), + "a successful retry must clear the safety-net marker" + ); + + backend.shutdown().await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stuck_unload_retry_ignores_non_ghost_states() { use crate::app::TaskResult; diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index f10d18126..a8dcfe028 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -74,10 +74,11 @@ impl AppContext { failures.push(TaskError::DashpaySidecarStorage { source }); } } - // Hold successful ghost cleanup claims until the whole wipe finishes. - // Otherwise a concurrent explicit load could repopulate the just-purged - // slot after the index sweep and make a reported-success wipe incomplete. - let mut successful_forgotten_cleanup_guards = Vec::new(); + // Hold every successful cleanup claim — ghost recovery and ordinary + // identity wipe alike — until the whole wipe finishes. Otherwise a + // concurrent explicit load could repopulate the just-purged slot after + // the index sweep and make a reported-success wipe incomplete. + let mut successful_identity_cleanup_guards = Vec::new(); let mut failed_forgotten_cleanup_guards = Vec::new(); let mut forgotten_marker_clear_candidates = Vec::new(); let mut forgotten_indexed_identities = Vec::new(); @@ -112,12 +113,22 @@ impl AppContext { match retry_result { Ok(true) => { if let Some(load_guard) = load_guard { - successful_forgotten_cleanup_guards.push(load_guard); + successful_identity_cleanup_guards.push(load_guard); } } Ok(false) => { let Some(load_guard) = load_guard else { - unreachable!("a successful retry check owns its load claim"); + // Only a claimed identity can reach a retry + // verdict, so this is a broken invariant rather + // than a runtime condition. Degrade instead of + // panicking: a panic here would abort a + // destructive wipe partway through. + tracing::error!( + identity_id = %identity_id, + "Forgotten identity reported a cleanup verdict without owning its load claim" + ); + failures.push(TaskError::WalletStorageNotReady); + continue; }; match self.local_identity_ids() { Ok(indexed) if indexed.contains(&identity_id) => { @@ -140,7 +151,7 @@ impl AppContext { match residue_result { Ok(()) => { forgotten_marker_clear_candidates.push(identity_id); - successful_forgotten_cleanup_guards.push(load_guard); + successful_identity_cleanup_guards.push(load_guard); } Err(error) => { failed_forgotten_cleanup_guards.push(load_guard); @@ -205,44 +216,11 @@ impl AppContext { // Wipe each identity's vault keys and det:identity:* records too — // Tier-1 keyless identity keys (incl. masternode voting/owner/payout) // are plaintext-recoverable, so a full wipe must remove them as well. - if forgotten_indexed_identities.contains(&owner) { - let mut attempts_remaining = IDENTITY_WIPE_ATTEMPTS; - let deletion_result = loop { - match self.delete_local_qualified_identity_retaining_claim(&owner) { - Err(TaskError::IdentityBusyWithLoad { .. }) - if attempts_remaining > 1 => - { - attempts_remaining -= 1; - tokio::time::sleep(IDENTITY_WIPE_RETRY_DELAY).await; - } - result => break result, - } - }; - match deletion_result { - Ok(load_guard) => { - forgotten_marker_clear_candidates.push(owner); - successful_forgotten_cleanup_guards.push(load_guard); - } - Err(error) => { - tracing::warn!( - owner = %owner, - "Identity private-key wipe failed during clear: {error:?}" - ); - let underlying_error = match error { - TaskError::IdentityUnloadCleanupFailed { source, .. } => { - *source - } - other => other, - }; - failures.push(underlying_error); - } - } - continue; - } - + // Every identity is deleted through the claim-retaining form so no + // slot reopens to a concurrent load while the sweep is still running. let mut attempts_remaining = IDENTITY_WIPE_ATTEMPTS; let deletion_result = loop { - match self.delete_local_qualified_identity(&owner) { + match self.delete_local_qualified_identity_retaining_claim(&owner) { Err(TaskError::IdentityBusyWithLoad { .. }) if attempts_remaining > 1 => { @@ -253,13 +231,18 @@ impl AppContext { } }; match deletion_result { - Ok(()) => {} - Err(e) => { + Ok(load_guard) => { + if forgotten_indexed_identities.contains(&owner) { + forgotten_marker_clear_candidates.push(owner); + } + successful_identity_cleanup_guards.push(load_guard); + } + Err(error) => { tracing::warn!( owner = %owner, - "Identity private-key wipe failed during clear: {e:?}" + "Identity private-key wipe failed during clear: {error:?}" ); - let underlying_error = match e { + let underlying_error = match error { TaskError::IdentityUnloadCleanupFailed { source, .. } => *source, other => other, }; @@ -290,23 +273,16 @@ impl AppContext { } } - // Resolve every forgotten-identity load claim now, right after the last - // step that still touches per-identity state (the marker retirement - // above). Everything from here on (`?`-fallible shielded cleanup - // included) must not be able to make these claims report `Failed` by - // early-returning past an unresolved guard — an identity whose ghost - // cleanup durably succeeded this pass must record `Loaded`, regardless - // of what happens to unrelated state afterward. - for load_guard in successful_forgotten_cleanup_guards { - load_guard.loaded(); - } - drop(failed_forgotten_cleanup_guards); - // Reset the upstream shielded coordinator (quiesces its sync loop and // empties the per-network store) and unlink DET's two retired legacy // shielded files. The legacy-file unlinks are synchronous and scoped - // strictly to THIS network's spv directory. - cleanup_legacy_shielded_files(backend.spv_storage_dir())?; + // strictly to THIS network's spv directory. Neither may early-return: + // the identity claims below are still held, and a `?` here would drop + // them unresolved, reporting durably-cleaned identities as `Failed`. + if let Err(error) = cleanup_legacy_shielded_files(backend.spv_storage_dir()) { + tracing::warn!(%error, "Legacy shielded file cleanup failed during clear"); + failures.push(error); + } if let Err(error) = backend.clear_shielded().await { tracing::warn!(%error, "Shielded coordinator reset failed during clear"); @@ -323,6 +299,19 @@ impl AppContext { self.has_wallet.store(false, Ordering::Relaxed); + // Resolve every identity load claim only now, once every step that can + // restore or touch per-identity state is done: marker retirement, the + // shielded and legacy-file cleanup, and the in-memory wallet teardown. + // Holding them this long is what makes a reported-clean wipe true — a + // claim released earlier reopens that identity's slot to a concurrent + // load, which could persist a fresh blob after this function's only + // index sweep. Nothing between guard capture and here early-returns, so + // an identity whose cleanup durably succeeded still records `Loaded`. + for load_guard in successful_identity_cleanup_guards { + load_guard.loaded(); + } + drop(failed_forgotten_cleanup_guards); + // Any secret-bearing delete that failed above means data may survive on // disk, so never report a clean wipe. The in-memory maps are still // cleared; the typed error tells the user to restart and retry. diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 21a13c63c..ddae56820 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -2571,6 +2571,200 @@ async fn clear_network_database_clears_forgotten_identity_markers() { .await; } +/// A minimal local identity carrying one plaintext key, so wiping it has real +/// vault state to remove. +fn keyed_qualified_identity( + identity_id: dash_sdk::platform::Identifier, + secret: [u8; 32], +) -> crate::model::qualified_identity::QualifiedIdentity { + use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, + }; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::IdentityPublicKey; + + let pv = PlatformVersion::latest(); + let key = IdentityPublicKey::random_key(1, Some(1), pv); + let mut private_keys = KeyStorage::default(); + private_keys.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::Clear(secret), + ), + ); + QualifiedIdentity { + identity: Identity::create_basic_identity(identity_id, pv).expect("basic identity"), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys, + dpns_names: vec![], + associated_wallets: std::collections::BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: std::collections::BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } +} + +/// An ordinary (never-unloaded) identity's exclusive claim must outlive the +/// whole wipe, not just its own deletion. Releasing it mid-sweep reopens that +/// identity's slot: a concurrent load could persist a fresh blob after the +/// wipe's only index sweep, and the wipe would still report success. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn clear_network_database_holds_ordinary_identity_claim_until_the_wipe_ends() { + use crate::context::identity_load_registry::IdentityLoadPhase; + use dash_sdk::platform::Identifier; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + // The enumeration index preserves insertion order, so the wipe reaches the + // ordinary identity first and the blocked ones after it. Their bounded + // retries are what keep the wipe running while the probe below happens. + let ordinary_id = Identifier::from([0x36u8; 32]); + ctx.insert_local_qualified_identity( + &keyed_qualified_identity(ordinary_id, [0x5Cu8; 32]), + &None, + ) + .expect("persist the ordinary identity"); + let blocked_ids: Vec = (0..5).map(|i| Identifier::from([0x40u8 + i; 32])).collect(); + for (i, blocked_id) in blocked_ids.iter().enumerate() { + ctx.insert_local_qualified_identity( + &keyed_qualified_identity(*blocked_id, [0x60u8 + i as u8; 32]), + &None, + ) + .expect("persist a blocked identity"); + } + let blocking_claims: Vec<_> = blocked_ids + .iter() + .map(|blocked_id| { + ctx.begin_identity_load(*blocked_id, None) + .expect("hold a claim the wipe has to retry against") + }) + .collect(); + + let wipe = tokio::spawn({ + let ctx = Arc::clone(&ctx); + async move { ctx.clear_network_database().await } + }); + + // Wait for the ordinary identity's blob purge — the last step of its + // deletion — then let the deletion call itself return. A claim released per + // deletion is gone microseconds after that point; a claim held to the end of + // the wipe survives the ~500ms the blocked identities spend retrying. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while ctx + .has_local_qualified_identity(&ordinary_id) + .expect("read the ordinary identity") + { + assert!( + std::time::Instant::now() < deadline, + "the wipe never reached the ordinary identity" + ); + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let concurrent_load = ctx.begin_identity_load(ordinary_id, None); + let phase_during_wipe = ctx.latest_identity_load_phase(&ordinary_id); + + drop(blocking_claims); + let _ = wipe.await.expect("the wipe task must not panic"); + + assert!( + matches!( + concurrent_load, + Err(TaskError::IdentityLoadInProgress { identity_id }) if identity_id == ordinary_id + ), + "a concurrent load must be excluded until the wipe finishes, got: {concurrent_load:?}" + ); + assert_eq!( + phase_during_wipe, + Some(IdentityLoadPhase::Running), + "the wiped identity's claim must still be unresolved mid-wipe" + ); + assert!( + !ctx.local_identity_ids() + .expect("read the identity index after the wipe") + .contains(&ordinary_id), + "the ordinary identity must still be wiped" + ); + + ctx.wallet_backend() + .expect("backend wired") + .shutdown() + .await; +} + +/// Guards are resolved after the legacy shielded-file cleanup, so that cleanup +/// must never early-return: a `?` there would drop every held claim unresolved +/// and report durably-wiped identities as failed loads. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_network_database_resolves_identity_claims_after_a_legacy_file_cleanup_failure() { + use crate::context::identity_load_registry::IdentityLoadPhase; + use dash_sdk::platform::Identifier; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let identity_id = Identifier::from([0x37u8; 32]); + ctx.insert_local_qualified_identity( + &keyed_qualified_identity(identity_id, [0x5Du8; 32]), + &None, + ) + .expect("persist local identity"); + + // A directory where a legacy shielded file is expected: the unlink fails + // with a non-NotFound error, leaving the wallet database in the same + // directory untouched. + std::fs::create_dir(backend.spv_storage_dir().join("det-shielded.sqlite")) + .expect("plant the legacy shielded cleanup fault"); + + let result = ctx.clear_network_database().await; + + backend.shutdown().await; + + match result { + Err(TaskError::WalletDataClearIncomplete { + failed, + first_error, + }) => { + assert_eq!(failed, 1, "the legacy file unlink is the only failure"); + assert!( + matches!(*first_error, TaskError::FileSystem { .. }), + "the aggregate must preserve the legacy file cleanup error" + ); + } + other => panic!("a legacy file cleanup failure must make clear incomplete: {other:?}"), + } + assert_eq!( + ctx.latest_identity_load_phase(&identity_id), + Some(IdentityLoadPhase::Loaded), + "an identity wiped durably must record a successful claim regardless of \ + unrelated cleanup failures" + ); + assert!( + ctx.local_identity_ids() + .expect("read the identity index after the wipe") + .is_empty(), + "the identity must still be wiped" + ); +} + /// Clear-all must fail before changing any state when the wallet backend is /// unavailable, because persisted secrets from an earlier run may still exist. #[tokio::test] diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index d349f0693..009ae7c06 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -22,6 +22,9 @@ use crate::ui::identities::register_dpns_name_screen::{ }; use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; +use crate::ui::identity::settings::{ + UNLOAD_DETAILS_LOAD_FAILED, identity_unload_confirmation_message, +}; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; use crate::wallet_backend::poison::MutexRecover; @@ -41,6 +44,9 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; +const REMOVE_IDENTITY_TIP: &str = "Permanently remove this identity and its private keys from this device. It remains on Dash \ + Platform."; + fn identity_removal_message( primary_cleanup_failed: bool, associated_cleanup_failed: bool, @@ -920,22 +926,34 @@ impl IdentitiesScreen { } // Remove - if ui.button("Remove").clickable_tooltip("Remove this identity from Dash Evo Tool (it'll still exist on Dash Platform)").clicked() { - let message = format!( - "Are you sure you want to no longer track this {identity_type} identity?\n\nIdentity ID: {identity_id}", - identity_type = qualified_identity.identity_type, - identity_id = qualified_identity.identity.id().to_string( - qualified_identity.identity_type.default_encoding() - ) - ); - self.identity_to_remove = - Some(qualified_identity.clone()); - self.remove_confirmation_dialog = Some( - ConfirmationDialog::new("Confirm Removal", message) - .confirm_text(Some("Yes")) - .cancel_text(Some("No")) - .danger_mode(true), - ); + if ui.button("Remove").clickable_tooltip(REMOVE_IDENTITY_TIP).clicked() { + // Same disclosure as Identity Hub → Settings: + // this permanently unloads the identity and + // deletes its private keys on this device. + match self.app_context.scheduled_vote_count_for_identity(&qualified_identity.identity.id()) { + Ok(scheduled_vote_count) => { + let message = identity_unload_confirmation_message( + qualified_identity, + scheduled_vote_count, + ); + self.identity_to_remove = + Some(qualified_identity.clone()); + self.remove_confirmation_dialog = Some( + ConfirmationDialog::new("Confirm Removal", message) + .confirm_text(Some("Yes")) + .cancel_text(Some("No")) + .danger_mode(true), + ); + } + Err(error) => { + MessageBanner::set_global( + ui.ctx(), + UNLOAD_DETAILS_LOAD_FAILED, + MessageType::Error, + ) + .with_details(&error); + } + } } // Up arrow diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 969248a8f..5b12160d2 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -81,10 +81,10 @@ const TIP_ADD_KEY: &str = const TIP_MANAGE_KEYS: &str = "View this identity's keys and their security settings."; const TIP_VIEW_USERNAMES: &str = "Open the complete list of your registered usernames."; const TIP_REFRESH: &str = "Fetch the latest state of this identity from the network."; -const TIP_UNLOAD_WALLET_DERIVED: &str = "Remove this identity and its local data from this device. It remains on \ - Dash Platform, and its wallet-derived private keys can be restored when you load it again."; -const TIP_UNLOAD_RECOVERY_REQUIRED: &str = "Remove this identity from this device, deleting its private keys and \ - local data. It remains on Dash Platform, but you will need its recovery information to load it again."; +const TIP_UNLOAD_WALLET_DERIVED: &str = "Remove this identity, its private keys, and its entry in this app from this \ + device. It remains on Dash Platform, and its wallet-derived private keys can be restored when you load it again."; +const TIP_UNLOAD_RECOVERY_REQUIRED: &str = "Remove this identity, its private keys, and its entry in this app from \ + this device. It remains on Dash Platform, but you will need its recovery information to load it again."; const TIP_SAVE_ALIAS: &str = "Save this name on this device."; const TIP_ID_COPY: &str = "Copy the full identity ID to your clipboard."; @@ -97,7 +97,7 @@ const ALIAS_HINT: &str = "For example: My main identity"; const ALIAS_SAVED: &str = "Name saved on this device."; const ALIAS_SAVE_FAILED: &str = "This name could not be saved on your device. Try again in a moment."; -const UNLOAD_DETAILS_LOAD_FAILED: &str = +pub(crate) const UNLOAD_DETAILS_LOAD_FAILED: &str = "The unload details could not be loaded. Try again in a moment."; const TIP_PROTX_COPY: &str = "Copy the masternode ID to your clipboard."; // Marker strings for controls without a matching backend task. Surfaced in @@ -1013,7 +1013,11 @@ fn identity_unload_tip_for(recovery_information_required: bool) -> &'static str } } -fn identity_unload_confirmation_message( +/// Confirmation body for unloading `identity`, naming what is deleted, what is +/// kept, and how many scheduled votes the unload cancels. Shared by every screen +/// that unloads or removes an identity so the disclosure cannot drift between +/// them. +pub(crate) fn identity_unload_confirmation_message( identity: &QualifiedIdentity, scheduled_vote_count: usize, ) -> String { @@ -1033,24 +1037,36 @@ fn identity_unload_confirmation_message_for( match (recovery_information_required, scheduled_vote_count > 0) { (true, true) => format!( "Identity \"{identity_label}\" will be permanently unloaded from this device, \ - deleting its private keys and local data. It remains on Dash Platform, but you will \ - need its recovery information to load it again. This also cancels \ - {scheduled_vote_count} scheduled vote(s)." + deleting its private keys and its entry in this app. Some synced network data, such \ + as contacts and payment history, is removed only by the \"Clear Database\" action in \ + Settings. This app remembers that you unloaded this identity, so automatic discovery \ + does not bring it back. It remains on Dash Platform, but you will need its recovery \ + information to load it again. This also cancels {scheduled_vote_count} scheduled \ + vote(s)." ), (true, false) => format!( "Identity \"{identity_label}\" will be permanently unloaded from this device, \ - deleting its private keys and local data. It remains on Dash Platform, but you will \ - need its recovery information to load it again." + deleting its private keys and its entry in this app. Some synced network data, such \ + as contacts and payment history, is removed only by the \"Clear Database\" action in \ + Settings. This app remembers that you unloaded this identity, so automatic discovery \ + does not bring it back. It remains on Dash Platform, but you will need its recovery \ + information to load it again." ), (false, true) => format!( "Identity \"{identity_label}\" will be permanently unloaded from this device, \ - deleting its local data. It remains on Dash Platform, and its wallet-derived private \ - keys can be restored when you load it again. This also cancels \ - {scheduled_vote_count} scheduled vote(s)." + deleting its private keys and its entry in this app. Some synced network data, such \ + as contacts and payment history, is removed only by the \"Clear Database\" action in \ + Settings. This app remembers that you unloaded this identity, so automatic discovery \ + does not bring it back. It remains on Dash Platform, and its wallet-derived private \ + keys can be restored when you load it again. This also cancels {scheduled_vote_count} \ + scheduled vote(s)." ), (false, false) => format!( "Identity \"{identity_label}\" will be permanently unloaded from this device, \ - deleting its local data. It remains on Dash Platform, and its wallet-derived private \ + deleting its private keys and its entry in this app. Some synced network data, such \ + as contacts and payment history, is removed only by the \"Clear Database\" action in \ + Settings. This app remembers that you unloaded this identity, so automatic discovery \ + does not bring it back. It remains on Dash Platform, and its wallet-derived private \ keys can be restored when you load it again." ), } @@ -1295,12 +1311,54 @@ mod tests { assert_eq!( identity_unload_confirmation_message_for("Wallet identity", false, 0), "Identity \"Wallet identity\" will be permanently unloaded from this device, \ - deleting its local data. It remains on Dash Platform, and its wallet-derived \ - private keys can be restored when you load it again." + deleting its private keys and its entry in this app. Some synced network data, such \ + as contacts and payment history, is removed only by the \"Clear Database\" action in \ + Settings. This app remembers that you unloaded this identity, so automatic discovery \ + does not bring it back. It remains on Dash Platform, and its wallet-derived private \ + keys can be restored when you load it again." ); assert_eq!(identity_unload_tip_for(false), TIP_UNLOAD_WALLET_DERIVED); } + /// Unloading is the action a user takes to sever a device↔identity link, so + /// the dialog must disclose what it does NOT remove and that the app keeps a + /// record of the unload — on every variant, not just one. + #[test] + fn unload_dialog_discloses_retained_data_and_the_remembered_unload() { + for recovery_information_required in [true, false] { + for scheduled_vote_count in [0, 2] { + let message = identity_unload_confirmation_message_for( + "Disclosure identity", + recovery_information_required, + scheduled_vote_count, + ); + assert!( + message.contains("deleting its private keys and its entry in this app"), + "the dialog must name what is actually deleted: {message}" + ); + assert!( + message.contains( + "Some synced network data, such as contacts and payment history, is \ + removed only by the \"Clear Database\" action in Settings." + ), + "the dialog must name the data it leaves behind and where to remove it: \ + {message}" + ); + assert!( + message.contains( + "This app remembers that you unloaded this identity, so automatic \ + discovery does not bring it back." + ), + "the dialog must disclose the durable record of the unload: {message}" + ); + assert!( + !message.contains("deleting its local data"), + "the dialog must not overstate the removal as all local data: {message}" + ); + } + } + } + #[test] fn unload_dialog_mentions_scheduled_votes_only_when_queued() { assert!( @@ -1339,8 +1397,11 @@ mod tests { assert_eq!( identity_unload_confirmation_message(&identity, 0), "Identity \"Mixed identity\" will be permanently unloaded from this device, deleting \ - its private keys and local data. It remains on Dash Platform, but you will need its \ - recovery information to load it again." + its private keys and its entry in this app. Some synced network data, such as \ + contacts and payment history, is removed only by the \"Clear Database\" action in \ + Settings. This app remembers that you unloaded this identity, so automatic discovery \ + does not bring it back. It remains on Dash Platform, but you will need its recovery \ + information to load it again." ); assert_eq!(identity_unload_tip(&identity), TIP_UNLOAD_RECOVERY_REQUIRED); } diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 222d99d75..65f947ccb 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -35,6 +35,9 @@ use crate::ui::components::password_input::PasswordInput; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identity::identity_picker_card::draw_type_badge; use crate::ui::identity::identity_pill::shorten_id; +use crate::ui::identity::settings::{ + UNLOAD_DETAILS_LOAD_FAILED, identity_unload_confirmation_message, +}; use crate::ui::masternodes::card::{ PLATFORM_IDENTITY_STATUS_TOOLTIP, platform_identity_status_label, }; @@ -52,6 +55,9 @@ const MISSING_VOTER_MESSAGE: &str = /// §7 copy: shown when the node has a voter identity but no open contests. const NO_OPEN_CONTESTS_MESSAGE: &str = "There are no open name contests for this node to vote on right now."; +/// §7 copy: the removal consequence that is specific to a node with a voter. +const REMOVE_VOTING_IDENTITY_DISCLOSURE: &str = + "This also removes the node's voting identity from this device."; /// The collapsible DPNS section header, with the open-contest count (TC-DPNS-02). fn dpns_section_header(open_contest_count: usize) -> String { @@ -964,16 +970,36 @@ impl MasternodeDetailView { ) .clicked() { - self.remove_dialog = Some( - ConfirmationDialog::new( - "Remove masternode", - "This removes the node and its voting identity from this device. \ - You can load it again later with its ProTxHash.", - ) - .danger_mode(true) - // §7 confirm verb (TC-US4-02). - .confirm_text(Some("Remove masternode")), - ); + // Same disclosure as Identity Hub → Settings, plus the voting + // identity this screen removes alongside the node. + let identity_id = self.identity.identity.id(); + match self + .app_context + .scheduled_vote_count_for_identity(&identity_id) + { + Ok(scheduled_vote_count) => { + self.remove_dialog = Some( + ConfirmationDialog::new( + "Remove masternode", + remove_masternode_confirmation_message( + &self.identity, + scheduled_vote_count, + ), + ) + .danger_mode(true) + // §7 confirm verb (TC-US4-02). + .confirm_text(Some("Remove masternode")), + ); + } + Err(error) => { + MessageBanner::set_global( + ui.ctx(), + UNLOAD_DETAILS_LOAD_FAILED, + MessageType::Error, + ) + .with_details(&error); + } + } } let mut action = None; @@ -997,6 +1023,20 @@ impl MasternodeDetailView { } } +/// Removing a node also unloads its associated voting identity, which the shared +/// identity disclosure does not cover — so that consequence is appended as its +/// own sentence when such an identity exists. +fn remove_masternode_confirmation_message( + identity: &QualifiedIdentity, + scheduled_vote_count: usize, +) -> String { + let message = identity_unload_confirmation_message(identity, scheduled_vote_count); + match identity.associated_voter_identity { + Some(_) => format!("{message}\n\n{REMOVE_VOTING_IDENTITY_DISCLOSURE}"), + None => message, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1019,6 +1059,62 @@ mod tests { ); } + /// Removal reuses the shared unload disclosure, and adds the voting-identity + /// consequence only for a node that actually has one. + #[test] + fn remove_dialog_reuses_the_shared_disclosure_and_names_the_voting_identity() { + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::{IdentityStatus, IdentityType}; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + + let pv = PlatformVersion::latest(); + let node = |voter: Option<(Identity, IdentityPublicKey)>| QualifiedIdentity { + identity: Identity::create_basic_identity(Identifier::from([0xD3; 32]), pv) + .expect("basic identity"), + associated_voter_identity: voter, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some("Node".to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + + let without_voter = remove_masternode_confirmation_message(&node(None), 0); + assert!( + without_voter.contains("will be permanently unloaded from this device"), + "the dialog must reuse the shared unload disclosure: {without_voter}" + ); + assert!( + !without_voter.contains(REMOVE_VOTING_IDENTITY_DISCLOSURE), + "a node without a voting identity must not claim one is removed: {without_voter}" + ); + + let voter = ( + Identity::create_basic_identity(Identifier::from([0xD4; 32]), pv) + .expect("voter identity"), + IdentityPublicKey::random_key(1, Some(1), pv), + ); + let with_voter = remove_masternode_confirmation_message(&node(Some(voter)), 2); + assert!( + with_voter.ends_with(REMOVE_VOTING_IDENTITY_DISCLOSURE), + "a node with a voting identity must disclose its removal: {with_voter}" + ); + assert!( + with_voter.contains("This also cancels 2 scheduled vote(s)."), + "the shared scheduled-vote clause must survive the addition: {with_voter}" + ); + } + #[test] fn remove_node_dispatches_the_shared_identity_removal_task() { let node_id = dash_sdk::platform::Identifier::from([0xD2; 32]); From 83dbfdf3328c9b1f25b90c0eb5b25f8a79fe4f94 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek Date: Mon, 27 Jul 2026 15:53:35 +0000 Subject: [PATCH 31/46] fix(identity): name the id in unload confirmations and share the node disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusing the shared unload confirmation on the Identities list dropped that dialog's explicit Base58 id. Aliases are user-set and not unique, so for an irreversible key-deleting action the confirmation could no longer tell two identically-aliased identities apart. The confirmation now names the id alongside the alias, and names it once when there is no alias. The Identities list also removes masternodes and evonodes, but built its dialog from the plain unload message, omitting that the node's voting identity goes with it. The removal variant that adds that sentence moves next to the shared unload copy in the identity settings module — where both screens already source their confirmation text — and both now use it. It degrades to the plain message for an identity without a voting identity, so no per-type branching is needed at either call site. Documentation accuracy: the wipe-completeness entry now states that only a concurrent load of the same identity is guarded, and the unload user story no longer implies DashPay data is removed where the unloaded identity is another identity's counterparty. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + docs/user-stories.md | 2 +- src/ui/identities/identities_screen.rs | 52 ++++++++++- src/ui/identity/settings.rs | 119 ++++++++++++++++++++++--- src/ui/masternodes/detail_screen.rs | 77 +--------------- 5 files changed, 162 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08964d15c..9ed3f346f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). the wipe has already passed it and still be reported as erased. A failure while removing retired shielded files no longer aborts the wipe partway or makes identities that were erased successfully look as though they failed. + This closes the race against a concurrent load of the same identity + specifically; other identity operations in flight during a wipe (refreshing, + adding a key, sending funds, and similar) are not guarded yet and are tracked + as follow-up work. - **A removal interrupted by a cleanup failure stays findable**: if the app cannot finish clearing an identity's local data, it now records the identity diff --git a/docs/user-stories.md b/docs/user-stories.md index 810bd799d..455987742 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -679,7 +679,7 @@ As a user, I want to unload one identity from this device so that I can recover cancelled. The confirmation also names the synced data that only a full database clear removes, and discloses that the app records the unload so automatic discovery does not bring the identity back. -- Unloading removes only the selected identity's local keys, metadata, DashPay overlays, queued scheduled votes, and device record while leaving the Platform identity unchanged. +- Unloading removes only the selected identity's local keys, metadata, its own DashPay overlay records, queued scheduled votes, and device record, while leaving the Platform identity unchanged. DashPay data held by another loaded identity that lists this one as a contact is not touched. - Other identities on the same wallet and the wallet's recovery seed remain available. --- diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 009ae7c06..fe0c261fa 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -23,7 +23,7 @@ use crate::ui::identities::register_dpns_name_screen::{ use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; use crate::ui::identity::settings::{ - UNLOAD_DETAILS_LOAD_FAILED, identity_unload_confirmation_message, + UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_message, }; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; @@ -930,9 +930,11 @@ impl IdentitiesScreen { // Same disclosure as Identity Hub → Settings: // this permanently unloads the identity and // deletes its private keys on this device. + // This list also removes nodes, so it needs + // the voting-identity variant. match self.app_context.scheduled_vote_count_for_identity(&qualified_identity.identity.id()) { Ok(scheduled_vote_count) => { - let message = identity_unload_confirmation_message( + let message = identity_removal_confirmation_message( qualified_identity, scheduled_vote_count, ); @@ -1317,12 +1319,56 @@ impl ScreenLike for IdentitiesScreen { #[cfg(test)] mod tests { - use super::{identity_removal_message, render_identity_name_cell}; + use super::{ + identity_removal_confirmation_message, identity_removal_message, render_identity_name_cell, + }; use crate::model::contested_name::PendingUsername; use crate::ui::components::pill::PENDING_USERNAME_PILL_LABEL; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; + /// This list removes masternodes and evonodes too, so its Remove dialog must + /// carry the same voting-identity disclosure as the masternode detail view. + #[test] + fn remove_dialog_discloses_the_voting_identity_for_a_node() { + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + use std::collections::BTreeMap; + + let pv = PlatformVersion::latest(); + let node = QualifiedIdentity { + identity: Identity::create_basic_identity(Identifier::from([0xE1; 32]), pv) + .expect("basic identity"), + associated_voter_identity: Some(( + Identity::create_basic_identity(Identifier::from([0xE2; 32]), pv) + .expect("voter identity"), + IdentityPublicKey::random_key(1, Some(1), pv), + )), + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + + let message = identity_removal_confirmation_message(&node, 0); + assert!( + message.ends_with("This also removes the node's voting identity from this device."), + "removing a node from the Identities list must disclose the voting identity: {message}" + ); + } + #[test] fn identity_removal_messages_distinguish_cleanup_outcomes() { let (message, message_type) = identity_removal_message(false, false, false); diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 5b12160d2..24e767563 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -99,6 +99,10 @@ const ALIAS_SAVE_FAILED: &str = "This name could not be saved on your device. Try again in a moment."; pub(crate) const UNLOAD_DETAILS_LOAD_FAILED: &str = "The unload details could not be loaded. Try again in a moment."; +/// The removal consequence carried only by an identity that has a voting +/// identity — masternodes and evonodes, on every screen that removes them. +const REMOVE_VOTING_IDENTITY_DISCLOSURE: &str = + "This also removes the node's voting identity from this device."; const TIP_PROTX_COPY: &str = "Copy the masternode ID to your clipboard."; // Marker strings for controls without a matching backend task. Surfaced in // disabled_tooltip and as a prefix on the row so users know it is a coming @@ -1022,21 +1026,46 @@ pub(crate) fn identity_unload_confirmation_message( scheduled_vote_count: usize, ) -> String { let identity_label = identity_unload_label(identity); + let base58_id = identity.identity.id().to_string(Encoding::Base58); + // Aliases are user-set and not unique, so a confirmation for an irreversible + // action must carry the id too. A label that already is the id says it once. + let identity_id = (identity_label != base58_id).then_some(base58_id.as_str()); identity_unload_confirmation_message_for( &identity_label, + identity_id, identity.requires_recovery_information_after_unload(), scheduled_vote_count, ) } +/// Removal confirmation: the shared unload disclosure, plus the voting-identity +/// consequence for an identity that carries one (masternodes and evonodes). +/// Safe for any identity type — an identity without a voter identity gets the +/// plain unload disclosure. +pub(crate) fn identity_removal_confirmation_message( + identity: &QualifiedIdentity, + scheduled_vote_count: usize, +) -> String { + let message = identity_unload_confirmation_message(identity, scheduled_vote_count); + match identity.associated_voter_identity { + Some(_) => format!("{message}\n\n{REMOVE_VOTING_IDENTITY_DISCLOSURE}"), + None => message, + } +} + fn identity_unload_confirmation_message_for( identity_label: &str, + identity_id: Option<&str>, recovery_information_required: bool, scheduled_vote_count: usize, ) -> String { + let identity_phrase = match identity_id { + Some(identity_id) => format!("\"{identity_label}\" (ID: {identity_id})"), + None => format!("\"{identity_label}\""), + }; match (recovery_information_required, scheduled_vote_count > 0) { (true, true) => format!( - "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + "Identity {identity_phrase} will be permanently unloaded from this device, \ deleting its private keys and its entry in this app. Some synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ @@ -1045,7 +1074,7 @@ fn identity_unload_confirmation_message_for( vote(s)." ), (true, false) => format!( - "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + "Identity {identity_phrase} will be permanently unloaded from this device, \ deleting its private keys and its entry in this app. Some synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ @@ -1053,7 +1082,7 @@ fn identity_unload_confirmation_message_for( information to load it again." ), (false, true) => format!( - "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + "Identity {identity_phrase} will be permanently unloaded from this device, \ deleting its private keys and its entry in this app. Some synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ @@ -1062,7 +1091,7 @@ fn identity_unload_confirmation_message_for( scheduled vote(s)." ), (false, false) => format!( - "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + "Identity {identity_phrase} will be permanently unloaded from this device, \ deleting its private keys and its entry in this app. Some synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ @@ -1309,7 +1338,7 @@ mod tests { #[test] fn unload_dialog_omits_recovery_warning_for_wallet_derived_keys() { assert_eq!( - identity_unload_confirmation_message_for("Wallet identity", false, 0), + identity_unload_confirmation_message_for("Wallet identity", None, false, 0), "Identity \"Wallet identity\" will be permanently unloaded from this device, \ deleting its private keys and its entry in this app. Some synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ @@ -1329,6 +1358,7 @@ mod tests { for scheduled_vote_count in [0, 2] { let message = identity_unload_confirmation_message_for( "Disclosure identity", + None, recovery_information_required, scheduled_vote_count, ); @@ -1362,15 +1392,75 @@ mod tests { #[test] fn unload_dialog_mentions_scheduled_votes_only_when_queued() { assert!( - identity_unload_confirmation_message_for("Voting identity", true, 3) + identity_unload_confirmation_message_for("Voting identity", None, true, 3) .contains("This also cancels 3 scheduled vote(s).") ); assert!( - !identity_unload_confirmation_message_for("Voting identity", true, 0) + !identity_unload_confirmation_message_for("Voting identity", None, true, 0) .contains("scheduled vote") ); } + /// Aliases are user-set and not unique, so a confirmation that named only + /// the alias could not tell two identically-aliased identities apart — on an + /// action that permanently deletes keys. + #[test] + fn unload_dialog_names_the_id_alongside_an_alias() { + let aliased = qualified_identity_with(13, Some("Shared alias")); + let aliased_id = aliased.identity.id().to_string(Encoding::Base58); + let message = identity_unload_confirmation_message(&aliased, 0); + assert!( + message.contains("Shared alias") && message.contains(&aliased_id), + "an aliased identity must be named by both its alias and its id: {message}" + ); + + let unaliased = qualified_identity_with(14, None); + let unaliased_id = unaliased.identity.id().to_string(Encoding::Base58); + let message = identity_unload_confirmation_message(&unaliased, 0); + assert!( + message.starts_with(&format!("Identity \"{unaliased_id}\" will be")), + "an unaliased identity is already unambiguous and names its id once: {message}" + ); + } + + /// The removal variant is used for every identity type, so it must add the + /// voting-identity consequence only for an identity that has one. + #[test] + fn removal_dialog_names_the_voting_identity_only_when_one_exists() { + let without_voter = identity_removal_confirmation_message( + &qualified_identity_with(15, Some("Plain identity")), + 0, + ); + assert!( + without_voter.contains("will be permanently unloaded from this device"), + "removal must reuse the shared unload disclosure: {without_voter}" + ); + assert!( + !without_voter.contains(REMOVE_VOTING_IDENTITY_DISCLOSURE), + "an identity without a voting identity must not claim one is removed: {without_voter}" + ); + + let mut node = qualified_identity_with(16, Some("Node")); + node.identity_type = crate::model::qualified_identity::IdentityType::Masternode; + node.associated_voter_identity = Some(( + Identity::create_basic_identity( + Identifier::from([0xD4; 32]), + PlatformVersion::latest(), + ) + .expect("voter identity"), + IdentityPublicKey::random_key(1, Some(1), PlatformVersion::latest()), + )); + let with_voter = identity_removal_confirmation_message(&node, 2); + assert!( + with_voter.ends_with(REMOVE_VOTING_IDENTITY_DISCLOSURE), + "a node with a voting identity must disclose its removal: {with_voter}" + ); + assert!( + with_voter.contains("This also cancels 2 scheduled vote(s)."), + "the shared scheduled-vote clause must survive the addition: {with_voter}" + ); + } + #[test] fn unload_dialog_warns_about_recovery_information_for_mixed_keys() { let mut identity = qualified_identity_with(12, Some("Mixed identity")); @@ -1394,14 +1484,17 @@ mod tests { ), ); + let identity_id = identity.identity.id().to_string(Encoding::Base58); assert_eq!( identity_unload_confirmation_message(&identity, 0), - "Identity \"Mixed identity\" will be permanently unloaded from this device, deleting \ - its private keys and its entry in this app. Some synced network data, such as \ - contacts and payment history, is removed only by the \"Clear Database\" action in \ - Settings. This app remembers that you unloaded this identity, so automatic discovery \ - does not bring it back. It remains on Dash Platform, but you will need its recovery \ - information to load it again." + format!( + "Identity \"Mixed identity\" (ID: {identity_id}) will be permanently unloaded \ + from this device, deleting its private keys and its entry in this app. Some \ + synced network data, such as contacts and payment history, is removed only by \ + the \"Clear Database\" action in Settings. This app remembers that you unloaded \ + this identity, so automatic discovery does not bring it back. It remains on Dash \ + Platform, but you will need its recovery information to load it again." + ) ); assert_eq!(identity_unload_tip(&identity), TIP_UNLOAD_RECOVERY_REQUIRED); } diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 65f947ccb..ec1d8d6eb 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -36,7 +36,7 @@ use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identity::identity_picker_card::draw_type_badge; use crate::ui::identity::identity_pill::shorten_id; use crate::ui::identity::settings::{ - UNLOAD_DETAILS_LOAD_FAILED, identity_unload_confirmation_message, + UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_message, }; use crate::ui::masternodes::card::{ PLATFORM_IDENTITY_STATUS_TOOLTIP, platform_identity_status_label, @@ -55,9 +55,6 @@ const MISSING_VOTER_MESSAGE: &str = /// §7 copy: shown when the node has a voter identity but no open contests. const NO_OPEN_CONTESTS_MESSAGE: &str = "There are no open name contests for this node to vote on right now."; -/// §7 copy: the removal consequence that is specific to a node with a voter. -const REMOVE_VOTING_IDENTITY_DISCLOSURE: &str = - "This also removes the node's voting identity from this device."; /// The collapsible DPNS section header, with the open-contest count (TC-DPNS-02). fn dpns_section_header(open_contest_count: usize) -> String { @@ -981,7 +978,7 @@ impl MasternodeDetailView { self.remove_dialog = Some( ConfirmationDialog::new( "Remove masternode", - remove_masternode_confirmation_message( + identity_removal_confirmation_message( &self.identity, scheduled_vote_count, ), @@ -1023,20 +1020,6 @@ impl MasternodeDetailView { } } -/// Removing a node also unloads its associated voting identity, which the shared -/// identity disclosure does not cover — so that consequence is appended as its -/// own sentence when such an identity exists. -fn remove_masternode_confirmation_message( - identity: &QualifiedIdentity, - scheduled_vote_count: usize, -) -> String { - let message = identity_unload_confirmation_message(identity, scheduled_vote_count); - match identity.associated_voter_identity { - Some(_) => format!("{message}\n\n{REMOVE_VOTING_IDENTITY_DISCLOSURE}"), - None => message, - } -} - #[cfg(test)] mod tests { use super::*; @@ -1059,62 +1042,6 @@ mod tests { ); } - /// Removal reuses the shared unload disclosure, and adds the voting-identity - /// consequence only for a node that actually has one. - #[test] - fn remove_dialog_reuses_the_shared_disclosure_and_names_the_voting_identity() { - use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; - use crate::model::qualified_identity::{IdentityStatus, IdentityType}; - use dash_sdk::dpp::dashcore::Network; - use dash_sdk::dpp::identity::Identity; - use dash_sdk::dpp::version::PlatformVersion; - use dash_sdk::platform::{Identifier, IdentityPublicKey}; - - let pv = PlatformVersion::latest(); - let node = |voter: Option<(Identity, IdentityPublicKey)>| QualifiedIdentity { - identity: Identity::create_basic_identity(Identifier::from([0xD3; 32]), pv) - .expect("basic identity"), - associated_voter_identity: voter, - associated_operator_identity: None, - associated_owner_key_id: None, - identity_type: IdentityType::Masternode, - alias: Some("Node".to_string()), - private_keys: KeyStorage::default(), - dpns_names: vec![], - associated_wallets: BTreeMap::new(), - secret_access: None, - wallet_index: None, - top_ups: BTreeMap::new(), - status: IdentityStatus::Active, - network: Network::Testnet, - }; - - let without_voter = remove_masternode_confirmation_message(&node(None), 0); - assert!( - without_voter.contains("will be permanently unloaded from this device"), - "the dialog must reuse the shared unload disclosure: {without_voter}" - ); - assert!( - !without_voter.contains(REMOVE_VOTING_IDENTITY_DISCLOSURE), - "a node without a voting identity must not claim one is removed: {without_voter}" - ); - - let voter = ( - Identity::create_basic_identity(Identifier::from([0xD4; 32]), pv) - .expect("voter identity"), - IdentityPublicKey::random_key(1, Some(1), pv), - ); - let with_voter = remove_masternode_confirmation_message(&node(Some(voter)), 2); - assert!( - with_voter.ends_with(REMOVE_VOTING_IDENTITY_DISCLOSURE), - "a node with a voting identity must disclose its removal: {with_voter}" - ); - assert!( - with_voter.contains("This also cancels 2 scheduled vote(s)."), - "the shared scheduled-vote clause must survive the addition: {with_voter}" - ); - } - #[test] fn remove_node_dispatches_the_shared_identity_removal_task() { let node_id = dash_sdk::platform::Identifier::from([0xD2; 32]); From e04458d4871d7b18399355379060bfd23069d369 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek Date: Mon, 27 Jul 2026 16:00:07 +0000 Subject: [PATCH 32/46] fix(identity): name nodes' reload path and unify the removal wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identifier disclosure added for aliased identities becomes its own sentence instead of a parenthetical glued into the naming sentence, so it stays a self-contained translation unit. Masternode and evonode identities have no wallet-derived keys, so they hit the confirmation arm promising that "recovery information" reloads them — which reads as a seed phrase they never had. Their confirmation now names the ProTxHash that actually loads them. The sentence is appended in the shared confirmation helper rather than in one screen's wrapper, so every path that unloads a node discloses it, not only the masternode page. The cleanup-failure error is reachable from both the Identities list's "Remove" and Identity Hub's "Unload this identity from this device", so its text no longer commits to one button's verb. The Identities list drops its own third wording of the removal tooltip in favour of the shared one, which branches on whether the identity's keys can be restored from a wallet — the removed constant claimed permanent key loss even for wallet-derived identities, contradicting the dialog it opened. The changelog now calls the wipe control "Clear Database" throughout, matching the dialog the app actually shows. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++- src/backend_task/error.rs | 6 +- src/ui/identities/identities_screen.rs | 7 +- src/ui/identity/settings.rs | 103 +++++++++++++++++++------ 4 files changed, 93 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed3f346f..b27e34e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,7 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). wallet discovery — so a leftover key from an earlier interrupted unload is always cleared before a fresh load replaces it, not just on one path. -- **"Delete all local data" no longer reports a clean wipe it did not finish**: +- **"Clear Database" no longer reports a clean wipe it did not finish**: the wipe now keeps every identity reserved until the last step is done, so an identity being loaded in the background cannot be written back to disk after the wipe has already passed it and still be reported as erased. A failure @@ -93,7 +93,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Previously such an identity could disappear from every list while its private keys remained on the device with nothing able to reach them. The message shown when this happens names that recovery — load the identity again, then - remove it a second time — instead of suggesting a retry that was impossible. + unload or remove it — instead of suggesting a retry that was impossible. - **Removing an identity now says what it really does**: the "Remove" action on the Identities list and "Remove masternode" on the masternode page show the @@ -104,7 +104,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). contacts and payment history that this action does not remove, and discloses that the app remembers the unload so automatic discovery does not bring the identity back. Removing a masternode still states that its voting identity - goes with it. + goes with it, and a masternode or evonode confirmation now says the node can + be loaded again with its ProTxHash instead of asking for recovery information + it never had. An identity with a name you chose is now also identified by its + full identifier, so two identities sharing a name cannot be confused on an + action that deletes keys. - **Wallet rename consistency**: renaming a wallet no longer overwrites other saved wallet details when metadata cannot be read. Overlapping renames and diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 595508a43..6fe6a4f2f 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -686,9 +686,11 @@ pub enum TaskError { /// /// The identity is already out of the local index when this is raised, so it /// is gone from every screen: the only recovery is to load it again, which - /// finishes the deferred cleanup, and then remove it a second time. + /// finishes the deferred cleanup, and then unload or remove it a second + /// time. Both entry points reach this, so the text names neither button's + /// verb alone. #[error( - "Some local data for identity {identity_id} could not be fully removed. Load this identity again, then remove it a second time to finish clearing it." + "Some local data for identity {identity_id} could not be fully removed. Load this identity again, then unload or remove it to finish clearing it." )] IdentityUnloadCleanupFailed { identity_id: Identifier, diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index fe0c261fa..405e409df 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -23,7 +23,7 @@ use crate::ui::identities::register_dpns_name_screen::{ use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; use crate::ui::identity::settings::{ - UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_message, + UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_message, identity_unload_tip, }; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; @@ -44,9 +44,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; -const REMOVE_IDENTITY_TIP: &str = "Permanently remove this identity and its private keys from this device. It remains on Dash \ - Platform."; - fn identity_removal_message( primary_cleanup_failed: bool, associated_cleanup_failed: bool, @@ -926,7 +923,7 @@ impl IdentitiesScreen { } // Remove - if ui.button("Remove").clickable_tooltip(REMOVE_IDENTITY_TIP).clicked() { + if ui.button("Remove").clickable_tooltip(identity_unload_tip(qualified_identity)).clicked() { // Same disclosure as Identity Hub → Settings: // this permanently unloads the identity and // deletes its private keys on this device. diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 24e767563..4e40a1824 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -103,6 +103,10 @@ pub(crate) const UNLOAD_DETAILS_LOAD_FAILED: &str = /// identity — masternodes and evonodes, on every screen that removes them. const REMOVE_VOTING_IDENTITY_DISCLOSURE: &str = "This also removes the node's voting identity from this device."; +/// How a node is loaded again. Nodes never came from a wallet seed, so the +/// generic recovery-information wording does not apply to them. +const NODE_RELOAD_GUIDANCE: &str = + "You can load a masternode or evonode identity again using its ProTxHash."; const TIP_PROTX_COPY: &str = "Copy the masternode ID to your clipboard."; // Marker strings for controls without a matching backend task. Surfaced in // disabled_tooltip and as a prefix on the row so users know it is a coming @@ -1005,7 +1009,10 @@ fn identity_unload_label(identity: &QualifiedIdentity) -> String { .unwrap_or_else(|| identity.identity.id().to_string(Encoding::Base58)) } -fn identity_unload_tip(identity: &QualifiedIdentity) -> &'static str { +/// Hover text for the control that unloads or removes `identity`, branching on +/// whether its keys can be restored from a wallet. Shared so no screen invents a +/// third wording that contradicts the dialog it opens. +pub(crate) fn identity_unload_tip(identity: &QualifiedIdentity) -> &'static str { identity_unload_tip_for(identity.requires_recovery_information_after_unload()) } @@ -1030,12 +1037,21 @@ pub(crate) fn identity_unload_confirmation_message( // Aliases are user-set and not unique, so a confirmation for an irreversible // action must carry the id too. A label that already is the id says it once. let identity_id = (identity_label != base58_id).then_some(base58_id.as_str()); - identity_unload_confirmation_message_for( + let message = identity_unload_confirmation_message_for( &identity_label, identity_id, identity.requires_recovery_information_after_unload(), scheduled_vote_count, - ) + ); + // Nodes are loaded by ProTxHash, never from a wallet seed, so the generic + // recovery-information wording would send their owners hunting for a + // recovery phrase they never had. + match identity.identity_type { + IdentityType::Masternode | IdentityType::Evonode => { + format!("{message} {NODE_RELOAD_GUIDANCE}") + } + IdentityType::User => message, + } } /// Removal confirmation: the shared unload disclosure, plus the voting-identity @@ -1059,14 +1075,18 @@ fn identity_unload_confirmation_message_for( recovery_information_required: bool, scheduled_vote_count: usize, ) -> String { - let identity_phrase = match identity_id { - Some(identity_id) => format!("\"{identity_label}\" (ID: {identity_id})"), - None => format!("\"{identity_label}\""), + // A complete sentence of its own, so it stays one translation unit rather + // than a fragment glued into the naming sentence. Empty when the identity is + // already named by its id. + let identity_identification = match identity_id { + Some(identity_id) => format!(" Its full identifier is {identity_id}."), + None => String::new(), }; match (recovery_information_required, scheduled_vote_count > 0) { (true, true) => format!( - "Identity {identity_phrase} will be permanently unloaded from this device, \ - deleting its private keys and its entry in this app. Some synced network data, such \ + "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + deleting its private keys and its entry in this app.{identity_identification} Some \ + synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ does not bring it back. It remains on Dash Platform, but you will need its recovery \ @@ -1074,16 +1094,18 @@ fn identity_unload_confirmation_message_for( vote(s)." ), (true, false) => format!( - "Identity {identity_phrase} will be permanently unloaded from this device, \ - deleting its private keys and its entry in this app. Some synced network data, such \ + "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + deleting its private keys and its entry in this app.{identity_identification} Some \ + synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ does not bring it back. It remains on Dash Platform, but you will need its recovery \ information to load it again." ), (false, true) => format!( - "Identity {identity_phrase} will be permanently unloaded from this device, \ - deleting its private keys and its entry in this app. Some synced network data, such \ + "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + deleting its private keys and its entry in this app.{identity_identification} Some \ + synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ does not bring it back. It remains on Dash Platform, and its wallet-derived private \ @@ -1091,8 +1113,9 @@ fn identity_unload_confirmation_message_for( scheduled vote(s)." ), (false, false) => format!( - "Identity {identity_phrase} will be permanently unloaded from this device, \ - deleting its private keys and its entry in this app. Some synced network data, such \ + "Identity \"{identity_label}\" will be permanently unloaded from this device, \ + deleting its private keys and its entry in this app.{identity_identification} Some \ + synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ does not bring it back. It remains on Dash Platform, and its wallet-derived private \ @@ -1410,8 +1433,12 @@ mod tests { let aliased_id = aliased.identity.id().to_string(Encoding::Base58); let message = identity_unload_confirmation_message(&aliased, 0); assert!( - message.contains("Shared alias") && message.contains(&aliased_id), - "an aliased identity must be named by both its alias and its id: {message}" + message.contains("Identity \"Shared alias\" will be"), + "an aliased identity must still be named by its alias: {message}" + ); + assert!( + message.contains(&format!("Its full identifier is {aliased_id}.")), + "an aliased identity must also be named by its id, as its own sentence: {message}" ); let unaliased = qualified_identity_with(14, None); @@ -1419,7 +1446,36 @@ mod tests { let message = identity_unload_confirmation_message(&unaliased, 0); assert!( message.starts_with(&format!("Identity \"{unaliased_id}\" will be")), - "an unaliased identity is already unambiguous and names its id once: {message}" + "an unaliased identity is named by its id: {message}" + ); + assert!( + !message.contains("Its full identifier is"), + "an identity already named by its id must not repeat it: {message}" + ); + } + + /// Nodes are loaded by ProTxHash, never from a wallet seed, so the generic + /// recovery-information wording would send their owners looking for a + /// recovery phrase that never existed. + #[test] + fn unload_dialog_points_nodes_at_their_protxhash() { + for identity_type in [IdentityType::Masternode, IdentityType::Evonode] { + let mut node = qualified_identity_with(17, Some("Node")); + node.identity_type = identity_type; + let message = identity_unload_confirmation_message(&node, 0); + assert!( + message.ends_with(NODE_RELOAD_GUIDANCE), + "a {identity_type:?} must be told how it is actually loaded again: {message}" + ); + } + + let user = identity_unload_confirmation_message( + &qualified_identity_with(18, Some("User identity")), + 0, + ); + assert!( + !user.contains(NODE_RELOAD_GUIDANCE), + "an ordinary identity must not be given node guidance: {user}" ); } @@ -1488,12 +1544,13 @@ mod tests { assert_eq!( identity_unload_confirmation_message(&identity, 0), format!( - "Identity \"Mixed identity\" (ID: {identity_id}) will be permanently unloaded \ - from this device, deleting its private keys and its entry in this app. Some \ - synced network data, such as contacts and payment history, is removed only by \ - the \"Clear Database\" action in Settings. This app remembers that you unloaded \ - this identity, so automatic discovery does not bring it back. It remains on Dash \ - Platform, but you will need its recovery information to load it again." + "Identity \"Mixed identity\" will be permanently unloaded from this device, \ + deleting its private keys and its entry in this app. Its full identifier is \ + {identity_id}. Some synced network data, such as contacts and payment history, \ + is removed only by the \"Clear Database\" action in Settings. This app remembers \ + that you unloaded this identity, so automatic discovery does not bring it back. \ + It remains on Dash Platform, but you will need its recovery information to load \ + it again." ) ); assert_eq!(identity_unload_tip(&identity), TIP_UNLOAD_RECOVERY_REQUIRED); From 88998621084518587ac6d5f548dc34e06960f1f9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek Date: Mon, 27 Jul 2026 16:11:54 +0000 Subject: [PATCH 33/46] test(identity): cover the forgotten-and-indexed claim hand-off in the wipe The forgotten-identity classification hands an identity that is still indexed to the owners loop by releasing its claim and letting that loop reacquire one, so a load can win the gap between them. The invariant comment claimed every claim is held to the end of the wipe, which is not true of that one hand-off; it now says so, and says what the gap costs. The behaviour that does hold either way had no coverage: the wipe never reports success while leaving that identity behind. It owns the identity and removes it, or it cannot claim it and reports the clear incomplete. The new test asserts that pair rather than the gap itself, so it stays correct if the hand-off later starts retaining its claim. Co-Authored-By: Claude Opus 5 --- src/context/wallet_lifecycle/spv.rs | 11 ++- src/context/wallet_lifecycle/tests.rs | 99 +++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index a8dcfe028..c166050ac 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -133,7 +133,11 @@ impl AppContext { match self.local_identity_ids() { Ok(indexed) if indexed.contains(&identity_id) => { // The normal indexed wipe below owns this - // identity and acquires its own claim. + // identity and acquires its own claim. This + // arm's claim drops here, so a load can win + // the gap before that reacquisition — the + // wipe then fails to claim it and reports + // incomplete rather than a clean sweep. forgotten_indexed_identities.push(identity_id); } Ok(_) => { @@ -299,7 +303,7 @@ impl AppContext { self.has_wallet.store(false, Ordering::Relaxed); - // Resolve every identity load claim only now, once every step that can + // Resolve the claims collected above only now, once every step that can // restore or touch per-identity state is done: marker retirement, the // shielded and legacy-file cleanup, and the in-memory wallet teardown. // Holding them this long is what makes a reported-clean wipe true — a @@ -307,6 +311,9 @@ impl AppContext { // load, which could persist a fresh blob after this function's only // index sweep. Nothing between guard capture and here early-returns, so // an identity whose cleanup durably succeeded still records `Loaded`. + // The forgotten-and-still-indexed classification above is the one claim + // not collected here: it hands off to the owners loop by reacquiring, + // and a load winning that gap makes the wipe report incomplete. for load_guard in successful_identity_cleanup_guards { load_guard.loaded(); } diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index ddae56820..a0f3c539a 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -2707,6 +2707,105 @@ async fn clear_network_database_holds_ordinary_identity_claim_until_the_wipe_end .await; } +/// An identity that is both forgotten-marked and still indexed is classified in +/// the forgotten pass, which releases its claim and leaves the owners loop to +/// reacquire one, so a load can win that hand-off gap. However the race lands, +/// the wipe must never report success while leaving that identity on disk: it +/// either owns the identity and removes it, or fails to claim it and reports the +/// clear incomplete. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn clear_network_database_never_reports_success_for_a_reclaimed_forgotten_identity() { + use crate::context::identity_load_registry::IdentityLoadPhase; + use dash_sdk::platform::Identifier; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + // Inserted first, so the owners loop stalls on their held claims long enough + // for the probe below to reach the target's hand-off window. + let blocked_ids: Vec = (0..5).map(|i| Identifier::from([0x50u8 + i; 32])).collect(); + for (i, blocked_id) in blocked_ids.iter().enumerate() { + ctx.insert_local_qualified_identity( + &keyed_qualified_identity(*blocked_id, [0x70u8 + i as u8; 32]), + &None, + ) + .expect("persist a blocked identity"); + } + + let target_id = Identifier::from([0x99u8; 32]); + ctx.insert_local_qualified_identity(&keyed_qualified_identity(target_id, [0x98u8; 32]), &None) + .expect("persist target identity"); + ctx.db() + .record_forgotten_identity(Network::Testnet, &target_id) + .expect("mark the target forgotten while it is still indexed"); + + let blocking_claims: Vec<_> = blocked_ids + .iter() + .map(|blocked_id| { + ctx.begin_identity_load(*blocked_id, None) + .expect("hold a claim the wipe has to retry against") + }) + .collect(); + + let wipe = tokio::spawn({ + let ctx = Arc::clone(&ctx); + async move { ctx.clear_network_database().await } + }); + + // Wait for the classification pass to release the target's claim, then race + // the owners loop for it exactly as a background load would. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !wipe.is_finished() + && !matches!( + ctx.latest_identity_load_phase(&target_id), + Some(IdentityLoadPhase::Failed) + ) + { + assert!( + std::time::Instant::now() < deadline, + "the target's classification claim was never observed being released" + ); + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + let concurrent_load = ctx.begin_identity_load(target_id, None); + let load_won_the_gap = concurrent_load.is_ok(); + + drop(blocking_claims); + let result = wipe.await.expect("the wipe task must not panic"); + let target_survived = ctx + .has_local_qualified_identity(&target_id) + .expect("read the target after the wipe"); + // Only now — the claim has to outlive the wipe to contest it at all. + drop(concurrent_load); + + if load_won_the_gap { + assert!( + matches!(result, Err(TaskError::WalletDataClearIncomplete { .. })), + "a wipe that could not claim the identity must report incomplete: {result:?}" + ); + assert!( + target_survived, + "an incomplete report must correspond to an identity actually left on disk" + ); + } else { + assert!( + result.is_ok(), + "an uncontested wipe must succeed: {result:?}" + ); + assert!( + !target_survived, + "a successful wipe must leave no trace of the identity" + ); + } + + ctx.wallet_backend() + .expect("backend wired") + .shutdown() + .await; +} + /// Guards are resolved after the legacy shielded-file cleanup, so that cleanup /// must never early-return: a `?` there would drop every held claim unresolved /// and report durably-wiped identities as failed loads. From 68f5c513971f0978a5793510be47c79d17dc3564 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek Date: Mon, 27 Jul 2026 16:15:00 +0000 Subject: [PATCH 34/46] fix(identity): stop node reload guidance reading as a key restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node's confirmation ends with "you will need its recovery information to load it again", and the ProTxHash sentence added after it said only that the identity can be loaded again with its ProTxHash. Read together, that offers the ProTxHash as the recovery information — on a dialog that is about to delete the node's voting, owner and payout keys, which the ProTxHash does not restore. The sentence now says those keys have to be entered again. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++-- src/ui/identity/settings.rs | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b27e34e08..8463691e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,8 +105,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). that the app remembers the unload so automatic discovery does not bring the identity back. Removing a masternode still states that its voting identity goes with it, and a masternode or evonode confirmation now says the node can - be loaded again with its ProTxHash instead of asking for recovery information - it never had. An identity with a name you chose is now also identified by its + be loaded again with its ProTxHash — while stating that its private keys have + to be entered again — instead of asking for recovery information it never had. An identity with a name you chose is now also identified by its full identifier, so two identities sharing a name cannot be confused on an action that deletes keys. diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 4e40a1824..2750360af 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -103,10 +103,11 @@ pub(crate) const UNLOAD_DETAILS_LOAD_FAILED: &str = /// identity — masternodes and evonodes, on every screen that removes them. const REMOVE_VOTING_IDENTITY_DISCLOSURE: &str = "This also removes the node's voting identity from this device."; -/// How a node is loaded again. Nodes never came from a wallet seed, so the -/// generic recovery-information wording does not apply to them. -const NODE_RELOAD_GUIDANCE: &str = - "You can load a masternode or evonode identity again using its ProTxHash."; +/// How a node is loaded again. Nodes never came from a wallet seed, but the +/// ProTxHash restores only the entry — never the keys this action deletes, so +/// the sentence must not read as if it replaced them. +const NODE_RELOAD_GUIDANCE: &str = "You can load a masternode or evonode identity again using its \ + ProTxHash, but you must enter its private keys again."; const TIP_PROTX_COPY: &str = "Copy the masternode ID to your clipboard."; // Marker strings for controls without a matching backend task. Surfaced in // disabled_tooltip and as a prefix on the row so users know it is a coming @@ -1456,7 +1457,10 @@ mod tests { /// Nodes are loaded by ProTxHash, never from a wallet seed, so the generic /// recovery-information wording would send their owners looking for a - /// recovery phrase that never existed. + /// recovery phrase that never existed. The ProTxHash restores only the + /// entry, so the same sentence has to say the keys come back separately — + /// otherwise it reads as "the ProTxHash is the recovery information", on a + /// confirmation for deleting those keys. #[test] fn unload_dialog_points_nodes_at_their_protxhash() { for identity_type in [IdentityType::Masternode, IdentityType::Evonode] { @@ -1467,6 +1471,11 @@ mod tests { message.ends_with(NODE_RELOAD_GUIDANCE), "a {identity_type:?} must be told how it is actually loaded again: {message}" ); + assert!( + message.contains("you must enter its private keys again"), + "node guidance must not read as if the ProTxHash restored the deleted \ + keys: {message}" + ); } let user = identity_unload_confirmation_message( From 93e6ccf12f5f1cd563821e35c207add67e4178e6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek Date: Mon, 27 Jul 2026 16:23:12 +0000 Subject: [PATCH 35/46] fix(identity): choose the unload restoration clause by identity kind The confirmation built its "how you get it back" sentence for wallet-derived identities and then appended a node correction after it. Two contradictions came out of that: a node holding keys was offered its ProTxHash right after being told it needs recovery information, reading as though the ProTxHash were that recovery information; and a watch-only node was told its wallet-derived keys would be restored, which no node ever has. The clause is now selected by identity kind and composed into the message once. A node with keys is told it can be loaded again by ProTxHash and that those keys are entered again by hand; a watch-only node is told only that it can be loaded again, since it has nothing to recover. User identities keep their existing two clauses and their exact wording. The unload tooltip is branched the same way, for the same reason. Selecting the clause leaves the scheduled-vote count as the only thing the message arms still differ by, so the four arms collapse to two with no change to what any identity is shown. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +- src/ui/identity/settings.rs | 202 +++++++++++++++++++++++------------- 2 files changed, 135 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8463691e6..32c55a50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,9 +104,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). contacts and payment history that this action does not remove, and discloses that the app remembers the unload so automatic discovery does not bring the identity back. Removing a masternode still states that its voting identity - goes with it, and a masternode or evonode confirmation now says the node can - be loaded again with its ProTxHash — while stating that its private keys have - to be entered again — instead of asking for recovery information it never had. An identity with a name you chose is now also identified by its + goes with it, and a masternode or evonode confirmation now describes how that + node is really restored — loaded again with its ProTxHash, with its private + keys entered again by hand if it held any — instead of promising wallet + recovery it never had. An identity with a name you chose is now also identified by its full identifier, so two identities sharing a name cannot be confused on an action that deletes keys. diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 2750360af..464174e1b 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -85,6 +85,8 @@ const TIP_UNLOAD_WALLET_DERIVED: &str = "Remove this identity, its private keys, device. It remains on Dash Platform, and its wallet-derived private keys can be restored when you load it again."; const TIP_UNLOAD_RECOVERY_REQUIRED: &str = "Remove this identity, its private keys, and its entry in this app from \ this device. It remains on Dash Platform, but you will need its recovery information to load it again."; +const TIP_UNLOAD_NODE: &str = "Remove this node, its private keys, and its entry in this app from this device. It \ + remains on Dash Platform, and you can load it again using its ProTxHash."; const TIP_SAVE_ALIAS: &str = "Save this name on this device."; const TIP_ID_COPY: &str = "Copy the full identity ID to your clipboard."; @@ -103,11 +105,18 @@ pub(crate) const UNLOAD_DETAILS_LOAD_FAILED: &str = /// identity — masternodes and evonodes, on every screen that removes them. const REMOVE_VOTING_IDENTITY_DISCLOSURE: &str = "This also removes the node's voting identity from this device."; -/// How a node is loaded again. Nodes never came from a wallet seed, but the -/// ProTxHash restores only the entry — never the keys this action deletes, so -/// the sentence must not read as if it replaced them. -const NODE_RELOAD_GUIDANCE: &str = "You can load a masternode or evonode identity again using its \ - ProTxHash, but you must enter its private keys again."; +// How the identity is restored after the unload — one complete statement per +// identity kind. Nodes are loaded by ProTxHash and are never wallet-derived, so +// they never carry the wallet or recovery-information wording; the ProTxHash +// restores the entry, never the keys this action deletes. +const USER_RESTORATION_RECOVERY_REQUIRED: &str = + "It remains on Dash Platform, but you will need its recovery information to load it again."; +const USER_RESTORATION_WALLET_DERIVED: &str = "It remains on Dash Platform, and its wallet-derived \ + private keys can be restored when you load it again."; +const NODE_RESTORATION_WITH_KEYS: &str = "It remains on Dash Platform, and you can load it again \ + using its ProTxHash, but the private keys deleted here must be entered again by hand."; +const NODE_RESTORATION_WATCH_ONLY: &str = + "It remains on Dash Platform, and you can load it again using its ProTxHash."; const TIP_PROTX_COPY: &str = "Copy the masternode ID to your clipboard."; // Marker strings for controls without a matching backend task. Surfaced in // disabled_tooltip and as a prefix on the row so users know it is a coming @@ -1010,11 +1019,17 @@ fn identity_unload_label(identity: &QualifiedIdentity) -> String { .unwrap_or_else(|| identity.identity.id().to_string(Encoding::Base58)) } -/// Hover text for the control that unloads or removes `identity`, branching on -/// whether its keys can be restored from a wallet. Shared so no screen invents a -/// third wording that contradicts the dialog it opens. +/// Hover text for the control that unloads or removes `identity`. Shared so no +/// screen invents a wording that contradicts the dialog it opens, and branched +/// by identity kind for the same reason the dialog is: a node has no wallet to +/// restore keys from, so the wallet-derived wording never applies to it. pub(crate) fn identity_unload_tip(identity: &QualifiedIdentity) -> &'static str { - identity_unload_tip_for(identity.requires_recovery_information_after_unload()) + match identity.identity_type { + IdentityType::Masternode | IdentityType::Evonode => TIP_UNLOAD_NODE, + IdentityType::User => { + identity_unload_tip_for(identity.requires_recovery_information_after_unload()) + } + } } fn identity_unload_tip_for(recovery_information_required: bool) -> &'static str { @@ -1038,20 +1053,26 @@ pub(crate) fn identity_unload_confirmation_message( // Aliases are user-set and not unique, so a confirmation for an irreversible // action must carry the id too. A label that already is the id says it once. let identity_id = (identity_label != base58_id).then_some(base58_id.as_str()); - let message = identity_unload_confirmation_message_for( + identity_unload_confirmation_message_for( &identity_label, identity_id, - identity.requires_recovery_information_after_unload(), + identity_restoration_clause(identity), scheduled_vote_count, - ); - // Nodes are loaded by ProTxHash, never from a wallet seed, so the generic - // recovery-information wording would send their owners hunting for a - // recovery phrase they never had. + ) +} + +/// How `identity` is restored after the unload. Chosen by identity kind and +/// composed into the message once — a correction appended after a clause written +/// for a different kind of identity reads as a contradiction, not a correction. +fn identity_restoration_clause(identity: &QualifiedIdentity) -> &'static str { + let holds_unrecoverable_keys = identity.requires_recovery_information_after_unload(); match identity.identity_type { - IdentityType::Masternode | IdentityType::Evonode => { - format!("{message} {NODE_RELOAD_GUIDANCE}") + IdentityType::Masternode | IdentityType::Evonode if holds_unrecoverable_keys => { + NODE_RESTORATION_WITH_KEYS } - IdentityType::User => message, + IdentityType::Masternode | IdentityType::Evonode => NODE_RESTORATION_WATCH_ONLY, + IdentityType::User if holds_unrecoverable_keys => USER_RESTORATION_RECOVERY_REQUIRED, + IdentityType::User => USER_RESTORATION_WALLET_DERIVED, } } @@ -1073,54 +1094,33 @@ pub(crate) fn identity_removal_confirmation_message( fn identity_unload_confirmation_message_for( identity_label: &str, identity_id: Option<&str>, - recovery_information_required: bool, + restoration: &str, scheduled_vote_count: usize, ) -> String { - // A complete sentence of its own, so it stays one translation unit rather - // than a fragment glued into the naming sentence. Empty when the identity is - // already named by its id. + // Complete sentences of their own, so each stays one translation unit rather + // than a fragment glued into a neighbouring sentence. The identification is + // empty when the identity is already named by its id. let identity_identification = match identity_id { Some(identity_id) => format!(" Its full identifier is {identity_id}."), None => String::new(), }; - match (recovery_information_required, scheduled_vote_count > 0) { - (true, true) => format!( + match scheduled_vote_count > 0 { + true => format!( "Identity \"{identity_label}\" will be permanently unloaded from this device, \ deleting its private keys and its entry in this app.{identity_identification} Some \ synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ - does not bring it back. It remains on Dash Platform, but you will need its recovery \ - information to load it again. This also cancels {scheduled_vote_count} scheduled \ - vote(s)." - ), - (true, false) => format!( - "Identity \"{identity_label}\" will be permanently unloaded from this device, \ - deleting its private keys and its entry in this app.{identity_identification} Some \ - synced network data, such \ - as contacts and payment history, is removed only by the \"Clear Database\" action in \ - Settings. This app remembers that you unloaded this identity, so automatic discovery \ - does not bring it back. It remains on Dash Platform, but you will need its recovery \ - information to load it again." - ), - (false, true) => format!( - "Identity \"{identity_label}\" will be permanently unloaded from this device, \ - deleting its private keys and its entry in this app.{identity_identification} Some \ - synced network data, such \ - as contacts and payment history, is removed only by the \"Clear Database\" action in \ - Settings. This app remembers that you unloaded this identity, so automatic discovery \ - does not bring it back. It remains on Dash Platform, and its wallet-derived private \ - keys can be restored when you load it again. This also cancels {scheduled_vote_count} \ + does not bring it back. {restoration} This also cancels {scheduled_vote_count} \ scheduled vote(s)." ), - (false, false) => format!( + false => format!( "Identity \"{identity_label}\" will be permanently unloaded from this device, \ deleting its private keys and its entry in this app.{identity_identification} Some \ synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ Settings. This app remembers that you unloaded this identity, so automatic discovery \ - does not bring it back. It remains on Dash Platform, and its wallet-derived private \ - keys can be restored when you load it again." + does not bring it back. {restoration}" ), } } @@ -1362,7 +1362,12 @@ mod tests { #[test] fn unload_dialog_omits_recovery_warning_for_wallet_derived_keys() { assert_eq!( - identity_unload_confirmation_message_for("Wallet identity", None, false, 0), + identity_unload_confirmation_message_for( + "Wallet identity", + None, + USER_RESTORATION_WALLET_DERIVED, + 0, + ), "Identity \"Wallet identity\" will be permanently unloaded from this device, \ deleting its private keys and its entry in this app. Some synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ @@ -1378,12 +1383,17 @@ mod tests { /// record of the unload — on every variant, not just one. #[test] fn unload_dialog_discloses_retained_data_and_the_remembered_unload() { - for recovery_information_required in [true, false] { + for restoration in [ + USER_RESTORATION_RECOVERY_REQUIRED, + USER_RESTORATION_WALLET_DERIVED, + NODE_RESTORATION_WITH_KEYS, + NODE_RESTORATION_WATCH_ONLY, + ] { for scheduled_vote_count in [0, 2] { let message = identity_unload_confirmation_message_for( "Disclosure identity", None, - recovery_information_required, + restoration, scheduled_vote_count, ); assert!( @@ -1416,12 +1426,22 @@ mod tests { #[test] fn unload_dialog_mentions_scheduled_votes_only_when_queued() { assert!( - identity_unload_confirmation_message_for("Voting identity", None, true, 3) - .contains("This also cancels 3 scheduled vote(s).") + identity_unload_confirmation_message_for( + "Voting identity", + None, + USER_RESTORATION_RECOVERY_REQUIRED, + 3, + ) + .contains("This also cancels 3 scheduled vote(s).") ); assert!( - !identity_unload_confirmation_message_for("Voting identity", None, true, 0) - .contains("scheduled vote") + !identity_unload_confirmation_message_for( + "Voting identity", + None, + USER_RESTORATION_RECOVERY_REQUIRED, + 0, + ) + .contains("scheduled vote") ); } @@ -1455,26 +1475,66 @@ mod tests { ); } - /// Nodes are loaded by ProTxHash, never from a wallet seed, so the generic - /// recovery-information wording would send their owners looking for a - /// recovery phrase that never existed. The ProTxHash restores only the - /// entry, so the same sentence has to say the keys come back separately — - /// otherwise it reads as "the ProTxHash is the recovery information", on a - /// confirmation for deleting those keys. + /// A node is never wallet-derived and is loaded by ProTxHash, so neither the + /// wallet-derived nor the recovery-information wording may reach it — in + /// either of its two real states. Asserting only on an appended sentence + /// misses a contradictory claim made earlier in the same message, so these + /// assert the whole composed string. #[test] - fn unload_dialog_points_nodes_at_their_protxhash() { + fn unload_dialog_gives_nodes_a_restoration_clause_that_fits_them() { for identity_type in [IdentityType::Masternode, IdentityType::Evonode] { - let mut node = qualified_identity_with(17, Some("Node")); - node.identity_type = identity_type; - let message = identity_unload_confirmation_message(&node, 0); + // Watch-only: no keys imported, so there is nothing to recover and + // no key-restoration claim to make. + let mut watch_only = qualified_identity_with(17, Some("Node")); + watch_only.identity_type = identity_type; + assert!( + !watch_only.requires_recovery_information_after_unload(), + "fixture check: a keyless node needs no recovery information" + ); + let message = identity_unload_confirmation_message(&watch_only, 0); + assert!( + message.ends_with(NODE_RESTORATION_WATCH_ONLY), + "a watch-only {identity_type:?} must simply be loadable again: {message}" + ); + + // Keys present: they are deleted here and are not derivable from the + // ProTxHash, so the message must say they are re-entered by hand. + let mut keyed = watch_only.clone(); + let key = IdentityPublicKey::random_key(1, Some(1), PlatformVersion::latest()); + keyed.private_keys.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::InVault, + ), + ); assert!( - message.ends_with(NODE_RELOAD_GUIDANCE), - "a {identity_type:?} must be told how it is actually loaded again: {message}" + keyed.requires_recovery_information_after_unload(), + "fixture check: a node holding its own keys has no wallet to restore them" ); + let message = identity_unload_confirmation_message(&keyed, 0); assert!( - message.contains("you must enter its private keys again"), - "node guidance must not read as if the ProTxHash restored the deleted \ - keys: {message}" + message.ends_with(NODE_RESTORATION_WITH_KEYS), + "a keyed {identity_type:?} must be told its keys are re-entered: {message}" + ); + + for message in [ + identity_unload_confirmation_message(&watch_only, 0), + identity_unload_confirmation_message(&keyed, 0), + ] { + assert!( + !message.contains("wallet-derived"), + "a {identity_type:?} is never wallet-derived: {message}" + ); + assert!( + !message.contains("recovery information"), + "a {identity_type:?} has no recovery information to ask for: {message}" + ); + } + assert_eq!( + identity_unload_tip(&keyed), + TIP_UNLOAD_NODE, + "the tooltip must not promise a node's keys come back from a wallet" ); } @@ -1483,7 +1543,7 @@ mod tests { 0, ); assert!( - !user.contains(NODE_RELOAD_GUIDANCE), + !user.contains("ProTxHash"), "an ordinary identity must not be given node guidance: {user}" ); } From c0fdbfe8a29f88ea034281782953119a5e5c4e3d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:31:21 +0000 Subject: [PATCH 36/46] docs: align CHANGELOG wording with the forgotten+indexed wipe caveat (QA-004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wipe-completeness bullet claimed the concurrent-load race was closed without qualification. The code comment and regression test added for QA-001 document one narrow, pre-existing exception (a forgotten+indexed identity briefly reopens its slot between two internal steps) where a wipe can still lose the race — it just reports itself incomplete instead of succeeding silently. Narrow the CHANGELOG bullet to match. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c55a50e..121db3dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,10 +82,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). the wipe has already passed it and still be reported as erased. A failure while removing retired shielded files no longer aborts the wipe partway or makes identities that were erased successfully look as though they failed. - This closes the race against a concurrent load of the same identity - specifically; other identity operations in flight during a wipe (refreshing, - adding a key, sending funds, and similar) are not guarded yet and are tracked - as follow-up work. + This closes the race against a concurrent load of the same identity in the + common case; one narrow exception remains (an identity that is both marked + unloaded and still on the device briefly reopens its slot between two + internal steps), and a wipe that loses that narrow race still reports itself + incomplete rather than succeeding silently. Other identity operations in + flight during a wipe (refreshing, adding a key, sending funds, and similar) + are not guarded yet and are tracked as follow-up work. - **A removal interrupted by a cleanup failure stays findable**: if the app cannot finish clearing an identity's local data, it now records the identity From 2f93a4104b8fbf7bf77edaa2733ddad41bb03669 Mon Sep 17 00:00:00 2001 From: "Claudius the Magnificent AI, on behalf of lklimek" <8431764+Claudius-Maginificent@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:14:32 +0200 Subject: [PATCH 37/46] docs(database): document data.db as a frozen, read-only legacy artifact (#939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md described src/database/ as a live, general-purpose SQLite persistence layer. In production, an existing data.db is opened with SQLITE_OPEN_READ_ONLY (Database::open_legacy_read_only, src/app.rs) and the schema ladder in database/initialization.rs runs only on a fresh install with no data.db yet — never against an existing one. Nothing in the docs said so, and this silently misled a recent PR (#889) into adding a new SQL table there, which is unwritable after an install's first boot. Clarify in CLAUDE.md, src/database/mod.rs, and docs/kv-keys.md that database/ is a migration-read source and recovery artifact only; all new durable state belongs in DetKv or SecretStore. Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com> --- CLAUDE.md | 8 +++++--- docs/kv-keys.md | 5 +++++ src/database/mod.rs | 8 ++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e421bf6b6..0d204e8f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ Code lives by responsibility, not convenience: - **`model/`** — stateless data types and pure validation (format/length/charset). The single source of truth for validation. No `AppContext`, `Sdk`, DB, or `BackendTask`. All fee estimation goes in `model/fee_estimation.rs` — never inlined elsewhere. - **`backend_task/`** — async business logic, one submodule per domain; the authoritative enforcement layer. `TaskError` and its typed variants live in `backend_task/error.rs`. -- **`database/`** — SQLite persistence, one module per domain. +- **`database/`** — **Frozen legacy `data.db`, read-only in production.** Production opens an existing `data.db` with `SQLITE_OPEN_READ_ONLY` (`Database::open_legacy_read_only`, `src/app.rs`); the schema ladder in `database/initialization.rs` runs only on a fresh install that has no `data.db` yet, never on an existing one. Never add a table, column, or write path here — it becomes permanently unwritable after the install's first boot. All current durable state is a `DetKv` key (see `docs/kv-keys.md`) or a `SecretStore` entry (`wallet_backend/secret_seam.rs`); `database/` exists solely as a v0.9.3→v1.0 migration-read source and recovery artifact. - **`context/`** — `AppContext` submodules (`*_db.rs`, lifecycle, settings, status). - **`wallet_backend/`** — the wallet orchestration seam: adapters, views, backend-side live caches, signers, the secret chokepoint, the event bridge. All wallet secret bytes (HD seed, imported single key, identity private key) enter/leave the vault through ONE chokepoint, `wallet_backend/secret_seam.rs` (raw `SecretBytes`, no DET-side serialization). Per-secret at-rest encryption is implemented via `put_secret_protected`/`get_secret_protected` (Argon2id + XChaCha20-Poly1305, per-secret object-password envelope, AAD bound to `wallet_id ‖ label`); unprotected secrets use `put_secret`/`get_secret` (raw, keyless vault). Identity keys (imported/loaded, including masternode voting/owner/payout) enter unprotected (Tier-1 keyless) at load/creation time — the load flow has no password field — but can be sealed to Tier-2 per-identity afterward via `IdentityTask::ProtectIdentityKeys` (Key Info screen → "Add password protection…"; gated by vault-key scheme, not identity type). The keyless-vault residual is only no-password secrets and keys the user has not opted to protect. Design + migration: `docs/ai-design/2026-06-19-secret-storage-seam/`. - **`ui//`** — screens (`ScreenLike`). UI may *call* `model/` validators for instant feedback but never implements its own validation. @@ -151,7 +151,7 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow - **Wallet Backend (`wallet_backend/`)** — Wallet orchestration seam: adapters, views, backend-side live caches, signers, the secret chokepoint (`secret_seam.rs`), and the event bridge. A thin adapter over the upstream `platform-wallet` crate. - **Context (`context/`)** — `AppContext`: shared state — network config, SDK client, database, wallets, settings cache, connection health (`ConnectionStatus` / `SpvManager`), split into submodules (`identity_db.rs`, `wallet_lifecycle.rs`, `settings_db.rs`, etc.). Glue between layers. - **Model (`model/`)** — Pure data types and stateless validation (amounts, fees, settings, wallet/identity models). No side effects, no IO. All fee estimation lives in `model/fee_estimation.rs` — never inline fee math elsewhere. -- **Database (`database/`)** — SQLite persistence (rusqlite), one module per domain. Typed CRUD, no business decisions. +- **Database (`database/`)** — Frozen legacy `data.db`. Read-only in production (migration-source reads and recovery only); no new tables, columns, or writes. Current persistence is `DetKv` over `det-app.sqlite` / `platform-wallet.sqlite` (`docs/kv-keys.md`) and `SecretStore`. - **Platform Integration** — Chain sync, address derivation, asset-lock/identity handling, and the shielded coordinator come from the upstream **`platform-wallet`** crate (git dep, dashpay/platform); DET is a thin adapter over it via `wallet_backend/`. SPV health is surfaced through `SpvManager` → `ConnectionStatus`. (DET's bespoke `src/spv/` stack and the `core_zmq_listener` module were removed in the platform-wallet migration.) ### Layer Rules @@ -354,7 +354,9 @@ Consider whether a repeated or reused message belongs in a dedicated `TaskError` ## Database -Single SQLite connection wrapped in `Mutex`. Schema initialized in `database/initialization.rs`. Domain modules provide typed CRUD methods. Backend task errors use `TaskError` (`src/backend_task/error.rs`) — see App Task System section above. +`AppContext.db` is the **legacy** `data.db` — a frozen migration-read source and recovery artifact, not a general persistence layer. Production opens it with `SQLITE_OPEN_READ_ONLY` whenever the file already exists, and only initializes (runs `database/initialization.rs`'s schema ladder) on a fresh install that has none yet; consequently that ladder never executes against an existing production install, and any write attempted on one fails at the SQLite layer. **Never add a table, column, or write path to `database/`.** + +All current durable state lives in `DetKv` (wraps the upstream `platform_wallet_storage::KvStore`; two backing SQLite files, `det-app.sqlite` and `spv//platform-wallet.sqlite` — see `docs/kv-keys.md` for the full key registry) or `SecretStore` (`wallet_backend/secret_seam.rs`). New persistent state is a new `DetKv` key registered in `docs/kv-keys.md`, never a new SQL table. Backend task errors use `TaskError` (`src/backend_task/error.rs`) — see App Task System section above. ## Platform Targets diff --git a/docs/kv-keys.md b/docs/kv-keys.md index 7c36c167f..149387021 100644 --- a/docs/kv-keys.md +++ b/docs/kv-keys.md @@ -10,6 +10,11 @@ Three backing stores exist: | `platform-wallet.sqlite` | `/spv//platform-wallet.sqlite` | Per-network identities, tokens, contracts, DashPay overlays, platform addresses, selected wallet | | `SecretStore` | `/secrets/det-secrets.*` | Encrypted HD-wallet seed envelopes and imported single-key private bytes | +Deliberately absent from this table: the legacy `data.db` behind `src/database/`. It is a +frozen v0.9.3→v1.0 migration-read source, opened read-only in production whenever it already +exists — never a target for new state. New persistent state is always a new `DetKv` key +registered below, never a new SQL table. + In the per-domain tables below, a `Scope` of `None` denotes `DetScope::Global`. --- diff --git a/src/database/mod.rs b/src/database/mod.rs index b0b163c59..ccae4ee90 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +1,11 @@ +//! Frozen legacy `data.db` — a migration-read source and recovery artifact, +//! not a general persistence layer. Production opens an existing file +//! read-only (`Database::open_legacy_read_only`) and never runs the schema +//! ladder in `initialization.rs` against it; only a fresh install with no +//! `data.db` yet initializes and writes. Never add a table, column, or write +//! path here. Current durable state is a `DetKv` key (`docs/kv-keys.md`) or a +//! `SecretStore` entry (`wallet_backend/secret_seam.rs`). + mod initialization; #[cfg(test)] pub(crate) use initialization::DEFAULT_DB_VERSION; From e72fc8edd71a2d96b445407f6e3bc8996d89091d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:21:28 +0000 Subject: [PATCH 38/46] fix(identity): store the forgotten-identity marker in DetKv, not the frozen data.db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-7 marker landed in a new `forgotten_identities` SQL table in `data.db` behind a v39 migration arm. `data.db` is a frozen legacy artifact: `boot_inputs` opens an existing file with `open_legacy_read_only`, and the migration ladder only ever runs for a fresh install that has no file yet. So the feature worked for exactly one session on a brand-new install and never again — every later boot, and every pre-existing install, failed the marker INSERT. That write happens with a bare `?` before the identity leaves the index, so unload/removal failed outright rather than degrading, and discovery lost its guard against resurrecting a deliberately unloaded identity. The marker is now one more `DetKv` key, matching how the rest of `identity_db.rs` already persists identities: - `det:forgotten_identities:v1`, `DetScope::Global`, `BTreeSet<[u8; 32]>`. - Global, not `DetScope::Identity`: identity-scoped slots are reaped by the upstream `AFTER DELETE` soft-cascade when the identity row goes away, which is precisely when this marker has to survive. Per-network partitioning comes from the per-network `platform-wallet.sqlite`, the same way `det:identity_index:v1` already gets it, so the `network` parameter disappears from the API. - Retiring the last marker deletes the slot instead of storing an empty set, leaving no residue behind. - `TaskError::ForgottenIdentityStorage` now sources `KvAdapterError`, matching its neighbour `TopUpHistoryStorage`. `DEFAULT_DB_VERSION` goes back to 38 and the v39 arm is gone. PR #925 is unmerged, so no v39 install exists anywhere — this is a clean revert, not a migration of a migration. Marker semantics are unchanged: recorded on unload, cleared on an explicit reload, checked by discovery, retired by the full wipe. The SQL-table unit tests are reborn as `DetKv` tests on the existing `InMemoryKv` fixture, plus new coverage that the marker outlives its identity's scope purge and lives in the per-network store rather than the cross-network app k/v. The four fault-injection tests keep their exact intent by faulting `meta_global` through a second connection — the trick already used for `det:identity_index:v1` — and each was confirmed to still fail with its trigger defused, so none of them passes vacuously. Co-Authored-By: Claude Opus 5 --- docs/kv-keys.md | 7 +- src/backend_task/error.rs | 4 +- .../identity/discover_identities.rs | 17 +- src/backend_task/identity/load_guard.rs | 33 ++- src/backend_task/identity/load_identity.rs | 20 +- src/context/identity_db.rs | 258 ++++++++++++++++-- src/context/wallet_lifecycle/spv.rs | 5 +- src/context/wallet_lifecycle/tests.rs | 6 +- src/database/forgotten_identities.rs | 147 ---------- src/database/initialization.rs | 56 +--- src/database/mod.rs | 1 - 11 files changed, 289 insertions(+), 265 deletions(-) delete mode 100644 src/database/forgotten_identities.rs diff --git a/docs/kv-keys.md b/docs/kv-keys.md index 7c36c167f..6ed082f30 100644 --- a/docs/kv-keys.md +++ b/docs/kv-keys.md @@ -82,11 +82,14 @@ Source: `src/model/selected_wallet.rs`, `src/wallet_backend/mod.rs` The identity blob and top-up history are **identity-scoped** (`DetScope::Identity(&id)`) so the upstream soft-cascade reaps them when the identity row is deleted. `DetScope::Identity` has no cross-identity listing, so a Global `det:identity_index:v1` slot holds the complete id roster the load-all paths iterate. `det:identity_order:v1` is a separate user-ordering view (may lag the full set) and stays Global. +`det:forgotten_identities:v1` is Global for the opposite reason to the blob: the marker's whole purpose is to outlive the identity it names, so automatic discovery cannot resurrect a deliberate unload. An identity-scoped slot would be reaped by that same soft-cascade at exactly the moment the marker becomes load-bearing. Like the other Global identity keys it is per-network by virtue of the per-network store, not by anything in the key or the value. + | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| | `det:identity:v1` | `DetScope::Identity(&id)` | `platform-wallet.sqlite` | `StoredQualifiedIdentity` | Fields: `qi_bytes` (inner bincode, redacted in `Debug`), `status: u8`, `identity_type: String`, `wallet_hash: Option<[u8;32]>`, `wallet_index: Option` | | `det:identity_index:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | Complete enumeration index of stored identity ids | | `det:identity_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | User-chosen display ordering of identity ID raw bytes | +| `det:forgotten_identities:v1` | `None` | `platform-wallet.sqlite` | `BTreeSet<[u8;32]>` | Identities the user deliberately unloaded; discovery must not restore them. Retiring the last marker deletes the slot | | `det:top_ups:v1` | `DetScope::Identity(&id)` | `platform-wallet.sqlite` | `BTreeMap` | Top-up history: account index → credits | Source: `src/context/identity_db.rs` @@ -204,8 +207,8 @@ Source: `src/wallet_backend/single_key.rs` (`SINGLE_KEY_PRIV_LABEL_PREFIX`, `SIN | Store | Key count | |-------|-----------| | `det-app.sqlite` | 4 (settings, wallet-meta sidecar, single-key-meta sidecar, migration sentinel) | -| `platform-wallet.sqlite` | 21 (across 8 domains) | +| `platform-wallet.sqlite` | 22 (across 8 domains) | | `SecretStore` | 2 label patterns (seed envelopes, imported-key private bytes) | -| **Total** | **27** | +| **Total** | **28** | Prefixed/templated keys (e.g. `det:identity:`) are counted once per prefix, not per instance. `SecretStore` entries are counted as label-pattern families, not per-wallet instances. diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 6fe6a4f2f..9efd15613 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -705,13 +705,13 @@ pub enum TaskError { IdentityBusyWithLoad { identity_id: Identifier }, /// A user's choice to keep an unloaded identity off this device could not - /// be read or saved in the local database. + /// be read or saved in the per-network wallet k/v store. #[error( "This identity could not be kept unloaded. Check available disk space and try again." )] ForgottenIdentityStorage { #[source] - source: rusqlite::Error, + source: crate::wallet_backend::KvAdapterError, }, /// An identity top-up history record could not be persisted to the diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index d6a1a5eaf..45d17b161 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -622,17 +622,19 @@ mod tests { .is_some() ); - ctx.db() - .record_forgotten_identity(Network::Testnet, &identity_id) + ctx.record_forgotten_identity(&identity_id) .expect("record forgotten marker"); - ctx.db() - .execute( + let fault_connection = + rusqlite::Connection::open(backend.spv_storage_dir().join("platform-wallet.sqlite")) + .expect("open persister second handle"); + fault_connection + .execute_batch( "CREATE TRIGGER fail_discovery_marker_cleanup - BEFORE DELETE ON forgotten_identities + BEFORE DELETE ON meta_global + WHEN OLD.key = 'det:forgotten_identities:v1' BEGIN SELECT RAISE(FAIL, 'injected discovery marker cleanup failure'); END;", - [], ) .expect("install marker cleanup failure trigger"); let load_guard = ctx @@ -650,6 +652,9 @@ mod tests { "the injected cleanup fault must leave the marker in place" ); + fault_connection + .execute_batch("DROP TRIGGER fail_discovery_marker_cleanup;") + .expect("remove marker cleanup failure trigger"); backend.shutdown().await; } diff --git a/src/backend_task/identity/load_guard.rs b/src/backend_task/identity/load_guard.rs index 61334452b..86327e3be 100644 --- a/src/backend_task/identity/load_guard.rs +++ b/src/backend_task/identity/load_guard.rs @@ -38,7 +38,6 @@ mod tests { use super::*; use crate::context::identity_load_registry::IdentityLoadPhase; use crate::context::test_support::test_app_context; - use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::Purpose; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, @@ -82,22 +81,33 @@ mod tests { ); } - #[test] - fn cleanup_failure_after_persist_still_reports_loaded() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cleanup_failure_after_persist_still_reports_loaded() { + use crate::app::TaskResult; + use crate::utils::egui_mpsc::SenderAsync; + let temp_dir = tempfile::tempdir().expect("tempdir"); let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); let identity_id = Identifier::from([0x72; 32]); - ctx.db() - .record_forgotten_identity(Network::Testnet, &identity_id) + ctx.record_forgotten_identity(&identity_id) .expect("record forgotten marker"); - ctx.db() - .execute( + let fault_connection = + rusqlite::Connection::open(backend.spv_storage_dir().join("platform-wallet.sqlite")) + .expect("open persister second handle"); + fault_connection + .execute_batch( "CREATE TRIGGER fail_forgotten_marker_cleanup - BEFORE DELETE ON forgotten_identities + BEFORE DELETE ON meta_global + WHEN OLD.key = 'det:forgotten_identities:v1' BEGIN SELECT RAISE(FAIL, 'injected forgotten marker cleanup failure'); END;", - [], ) .expect("install cleanup failure trigger"); let token = ctx @@ -119,5 +129,10 @@ mod tests { .expect("read retained marker"), "the injected cleanup fault must leave the marker in place" ); + + fault_connection + .execute_batch("DROP TRIGGER fail_forgotten_marker_cleanup;") + .expect("remove cleanup failure trigger"); + backend.shutdown().await; } } diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 4b7568002..246d312e9 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -1192,8 +1192,7 @@ mod tests { let identity_id = qi.identity.id(); ctx.insert_local_qualified_identity(&qi, &None) .expect("insert first masternode identity"); - ctx.db() - .record_forgotten_identity(Network::Testnet, &identity_id) + ctx.record_forgotten_identity(&identity_id) .expect("record stale forgotten marker"); let input = IdentityInputToLoad { @@ -1647,17 +1646,19 @@ mod tests { let identity_id = qualified_identity.identity.id(); ctx.insert_local_qualified_identity(&qualified_identity, &None) .expect("durably persist loaded identity"); - ctx.db() - .record_forgotten_identity(Network::Testnet, &identity_id) + ctx.record_forgotten_identity(&identity_id) .expect("record forgotten marker"); - ctx.db() - .execute( + let fault_connection = + rusqlite::Connection::open(backend.spv_storage_dir().join("platform-wallet.sqlite")) + .expect("open persister second handle"); + fault_connection + .execute_batch( "CREATE TRIGGER fail_load_marker_cleanup - BEFORE DELETE ON forgotten_identities + BEFORE DELETE ON meta_global + WHEN OLD.key = 'det:forgotten_identities:v1' BEGIN SELECT RAISE(FAIL, 'injected load marker cleanup failure'); END;", - [], ) .expect("install marker cleanup failure trigger"); let token = ctx @@ -1681,6 +1682,9 @@ mod tests { "cleanup residue must not turn a committed load into a reported failure" ); + fault_connection + .execute_batch("DROP TRIGGER fail_load_marker_cleanup;") + .expect("remove marker cleanup failure trigger"); backend.shutdown().await; } diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 07c383ff3..b8154ec96 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -11,7 +11,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, RwLock}; /// Identity blob slot, scoped to [`DetScope::Identity`]. One entry per @@ -33,6 +33,13 @@ const IDENTITY_ORDER_KEY: &str = "det:identity_order:v1"; /// full set. const IDENTITY_INDEX_KEY: &str = "det:identity_index:v1"; +/// Global set of identities the user deliberately unloaded, which automatic +/// discovery must not restore. Lives in [`DetScope::Global`] because the marker +/// has to outlive the identity it names: a [`DetScope::Identity`] slot is reaped +/// by the upstream soft-cascade at exactly the moment the marker becomes load- +/// bearing. Per-network by virtue of the store, like [`IDENTITY_INDEX_KEY`]. +const FORGOTTEN_IDENTITIES_KEY: &str = "det:forgotten_identities:v1"; + /// Scheduled-vote slot key, scoped to [`DetScope::Identity`] of the /// voter. The full key is `det:scheduled_vote:` — the /// voter id is carried by the scope. @@ -69,6 +76,11 @@ fn top_up_err(source: KvAdapterError) -> TaskError { TaskError::TopUpHistoryStorage { source } } +/// Map a k/v adapter failure to the forgotten-identity marker storage error. +fn forgotten_err(source: KvAdapterError) -> TaskError { + TaskError::ForgottenIdentityStorage { source } +} + fn keep_first_unload_cleanup_error( cleanup_error: &mut Option, identity_id: Identifier, @@ -274,6 +286,49 @@ fn index_remove_identity(kv: &DetKv, identity_id: &[u8; 32]) -> std::result::Res .map_err(identity_err) } +/// Read the Global forgotten-identity marker set. Returns an empty set +/// when nothing on this network has ever been deliberately unloaded. +fn load_forgotten_identities(kv: &DetKv) -> std::result::Result, TaskError> { + Ok(kv + .get::>(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) + .map_err(forgotten_err)? + .unwrap_or_default()) +} + +/// Mark `identity_id` as deliberately unloaded. No-op when it is already +/// marked, so repeated unload attempts stay idempotent. +fn forgotten_add_identity( + kv: &DetKv, + identity_id: &[u8; 32], +) -> std::result::Result<(), TaskError> { + let mut forgotten = load_forgotten_identities(kv)?; + if !forgotten.insert(*identity_id) { + return Ok(()); + } + kv.put(DetScope::Global, FORGOTTEN_IDENTITIES_KEY, &forgotten) + .map_err(forgotten_err) +} + +/// Drop `identity_id`'s marker. No-op when it is not marked. Retiring the +/// last marker removes the slot outright rather than leaving an empty set +/// behind, so a device with nothing forgotten carries no marker record. +fn forgotten_remove_identity( + kv: &DetKv, + identity_id: &[u8; 32], +) -> std::result::Result<(), TaskError> { + let mut forgotten = load_forgotten_identities(kv)?; + if !forgotten.remove(identity_id) { + return Ok(()); + } + if forgotten.is_empty() { + return kv + .delete(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) + .map_err(forgotten_err); + } + kv.put(DetScope::Global, FORGOTTEN_IDENTITIES_KEY, &forgotten) + .map_err(forgotten_err) +} + /// Delete every Identity-scoped child of `id` (blob, top-up history, all /// scheduled votes) and prune the scheduled-vote voter index. Does not /// touch the Global identity index — callers decide whether to drop the @@ -965,14 +1020,30 @@ impl AppContext { self.delete_local_qualified_identity_inner(identifier, true) } + /// Record that automatic discovery must not restore an unloaded identity. + pub(crate) fn record_forgotten_identity( + &self, + identifier: &Identifier, + ) -> std::result::Result<(), TaskError> { + forgotten_add_identity(&self.det_kv()?, &identifier.to_buffer()) + } + /// Whether automatic discovery must leave this identity unloaded. pub(crate) fn is_identity_forgotten( &self, identifier: &Identifier, ) -> std::result::Result { - self.db - .is_identity_forgotten(self.network, identifier) - .map_err(|source| TaskError::ForgottenIdentityStorage { source }) + Ok(load_forgotten_identities(&self.det_kv()?)?.contains(&identifier.to_buffer())) + } + + /// Every identity deliberately unloaded on this network. + pub(crate) fn list_forgotten_identities( + &self, + ) -> std::result::Result, TaskError> { + Ok(load_forgotten_identities(&self.det_kv()?)? + .into_iter() + .map(Identifier::from) + .collect()) } /// Clear the discovery block after a user-requested load succeeds. @@ -980,9 +1051,7 @@ impl AppContext { &self, identifier: &Identifier, ) -> std::result::Result<(), TaskError> { - self.db - .clear_forgotten_identity(self.network, identifier) - .map_err(|source| TaskError::ForgottenIdentityStorage { source }) + forgotten_remove_identity(&self.det_kv()?, &identifier.to_buffer()) } /// Finish cleanup for a forgotten, unindexed identity whose blob remains. @@ -1033,9 +1102,7 @@ impl AppContext { self.cleanup_identity_after_index_removal(&identifier)?; if clear_forgotten_marker { - self.db - .clear_forgotten_identity(self.network, &identifier) - .map_err(|source| TaskError::ForgottenIdentityStorage { source })?; + forgotten_remove_identity(&self.det_kv()?, id)?; } Ok(true) } @@ -1152,9 +1219,7 @@ impl AppContext { }, )?; if remember_unload { - self.db - .record_forgotten_identity(self.network, identifier) - .map_err(|source| TaskError::ForgottenIdentityStorage { source })?; + self.record_forgotten_identity(identifier)?; } // Drop the identity from the index BEFORE the irreversible vault-key // clear: a fault in either of the next two steps must never leave a @@ -1163,10 +1228,7 @@ impl AppContext { // `purge_identity_scope`, since it reads the identity blob that // `purge_identity_scope` deletes. if let Err(error) = index_remove_identity(&kv, &id) { - if remember_unload - && let Err(rollback_error) = - self.db.clear_forgotten_identity(self.network, identifier) - { + if remember_unload && let Err(rollback_error) = forgotten_remove_identity(&kv, &id) { tracing::warn!( identity_id = %identifier, original_error = ?error, @@ -1182,8 +1244,7 @@ impl AppContext { // remembered unload wrote its marker above; every other caller gets // one here. Never mask the cleanup error with a marker-write failure. if !remember_unload - && let Err(marker_error) = - self.db.record_forgotten_identity(self.network, identifier) + && let Err(marker_error) = self.record_forgotten_identity(identifier) { tracing::warn!( identity_id = %identifier, @@ -1779,6 +1840,137 @@ mod tests { assert_eq!(load_identity_index(&kv).unwrap(), vec![id(2)]); } + // --------------------------------------------------------------- + // Forgotten-identity markers: the record that must outlive its + // identity, so automatic discovery cannot resurrect a deliberate + // unload. + // --------------------------------------------------------------- + + #[test] + fn forgotten_marker_round_trips_and_is_per_identity() { + let kv = empty_kv(); + assert!(load_forgotten_identities(&kv).unwrap().is_empty()); + + forgotten_add_identity(&kv, &id(1)).unwrap(); + forgotten_add_identity(&kv, &id(2)).unwrap(); + + let forgotten = load_forgotten_identities(&kv).unwrap(); + assert!(forgotten.contains(&id(1))); + assert!(forgotten.contains(&id(2))); + assert!(!forgotten.contains(&id(3))); + + forgotten_remove_identity(&kv, &id(1)).unwrap(); + let forgotten = load_forgotten_identities(&kv).unwrap(); + assert!(!forgotten.contains(&id(1))); + assert!( + forgotten.contains(&id(2)), + "clearing one marker keeps the rest" + ); + } + + #[test] + fn forgotten_marker_writes_are_idempotent() { + let kv = empty_kv(); + forgotten_add_identity(&kv, &id(1)).unwrap(); + forgotten_add_identity(&kv, &id(1)).unwrap(); + assert_eq!(load_forgotten_identities(&kv).unwrap().len(), 1); + + forgotten_remove_identity(&kv, &id(1)).unwrap(); + forgotten_remove_identity(&kv, &id(1)).unwrap(); + forgotten_remove_identity(&kv, &id(9)).unwrap(); + assert!(load_forgotten_identities(&kv).unwrap().is_empty()); + } + + #[test] + fn retiring_the_last_forgotten_marker_removes_the_slot() { + let kv = empty_kv(); + forgotten_add_identity(&kv, &id(1)).unwrap(); + forgotten_remove_identity(&kv, &id(1)).unwrap(); + assert!( + kv.get::>(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) + .unwrap() + .is_none(), + "an emptied marker set must leave no residual slot behind" + ); + } + + /// The marker exists to outlive the identity it names. Storing it in the + /// identity's own scope would hand it to the upstream soft-cascade, which + /// reaps that scope exactly when the identity goes away. + #[test] + fn forgotten_marker_outlives_its_identity_scope() { + let kv = empty_kv(); + put_identity(&kv, &id(1), "User"); + forgotten_add_identity(&kv, &id(1)).unwrap(); + + purge_identity_scope(&kv, &id(1)).unwrap(); + index_remove_identity(&kv, &id(1)).unwrap(); + + assert!( + load_forgotten_identities(&kv).unwrap().contains(&id(1)), + "purging every identity-scoped record must not touch the marker" + ); + assert!( + kv.list(DetScope::Identity(&id(1)), None) + .unwrap() + .is_empty(), + "the marker must not be one of the identity's own reapable slots" + ); + } + + /// Markers are partitioned by network through the store they live in, so + /// they must land in the per-network wallet k/v — a marker written to the + /// cross-network app k/v would keep the identity unloaded on every network. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn forgotten_markers_live_in_the_per_network_store() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + let identity_id = Identifier::from([0x2A; 32]); + + ctx.record_forgotten_identity(&identity_id) + .expect("record marker"); + + assert_eq!( + ctx.list_forgotten_identities().expect("list markers"), + vec![identity_id] + ); + assert!( + ctx.det_kv() + .expect("per-network k/v") + .get::>(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) + .expect("read per-network marker slot") + .is_some(), + "the marker belongs to the per-network wallet store" + ); + assert!( + ctx.app_kv() + .get::>(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) + .expect("read cross-network marker slot") + .is_none(), + "the cross-network app store must hold no marker" + ); + + ctx.clear_forgotten_identity_after_explicit_load(&identity_id) + .expect("clear marker"); + assert!( + ctx.list_forgotten_identities() + .expect("list cleared markers") + .is_empty() + ); + + backend.shutdown().await; + } + // --------------------------------------------------------------- // Identity-type tag: writer and filter share one stable mapping. // --------------------------------------------------------------- @@ -2616,11 +2808,14 @@ mod tests { END;", ) .expect("install identity-index trigger"); - ctx.db() - .locked_conn() + // The marker write is an upsert and its rollback a delete, so the two + // halves of the unload can be faulted independently: recording the + // marker still succeeds, only rolling it back fails. + fault_connection .execute_batch( "CREATE TRIGGER fail_forgotten_marker_rollback - BEFORE DELETE ON forgotten_identities + BEFORE DELETE ON meta_global + WHEN OLD.key = 'det:forgotten_identities:v1' BEGIN SELECT RAISE(FAIL, 'injected marker-rollback failure'); END;", @@ -2634,14 +2829,18 @@ mod tests { matches!(error, TaskError::IdentityStorage { .. }), "the original commit failure must remain primary, got {error:?}" ); + assert!( + ctx.is_identity_forgotten(&target_id) + .expect("read marker after the failed rollback"), + "a failed rollback leaves the marker the unload had already recorded" + ); fault_connection - .execute_batch("DROP TRIGGER fail_identity_index_commit;") - .expect("remove identity-index trigger"); - ctx.db() - .locked_conn() - .execute_batch("DROP TRIGGER fail_forgotten_marker_rollback;") - .expect("remove marker-rollback trigger"); + .execute_batch( + "DROP TRIGGER fail_identity_index_commit; + DROP TRIGGER fail_forgotten_marker_rollback;", + ) + .expect("remove injected triggers"); backend.shutdown().await; } @@ -3143,8 +3342,7 @@ mod tests { ); let purged_id = Identifier::from([0xA7; 32]); - ctx.db() - .record_forgotten_identity(Network::Testnet, &purged_id) + ctx.record_forgotten_identity(&purged_id) .expect("record marker without a blob"); assert!( !ctx.retry_stuck_unload_cleanup(&purged_id.to_buffer()) diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index c166050ac..105156ffd 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -82,7 +82,7 @@ impl AppContext { let mut failed_forgotten_cleanup_guards = Vec::new(); let mut forgotten_marker_clear_candidates = Vec::new(); let mut forgotten_indexed_identities = Vec::new(); - match self.db.list_forgotten_identities(self.network) { + match self.list_forgotten_identities() { Ok(forgotten_identities) => { for identity_id in forgotten_identities { let load_guard = self @@ -195,8 +195,7 @@ impl AppContext { } } } - Err(source) => { - let error = TaskError::ForgottenIdentityStorage { source }; + Err(error) => { tracing::warn!(error = ?error, "Forgotten identity listing failed during full wipe"); failures.push(error); } diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index a0f3c539a..56bb1b585 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -2546,8 +2546,7 @@ async fn clear_network_database_clears_forgotten_identity_markers() { .expect("ensure_wallet_backend should succeed offline"); let identity_id = Identifier::from([0x35u8; 32]); - ctx.db() - .record_forgotten_identity(Network::Testnet, &identity_id) + ctx.record_forgotten_identity(&identity_id) .expect("record forgotten marker before the wipe"); assert!( ctx.is_identity_forgotten(&identity_id) @@ -2737,8 +2736,7 @@ async fn clear_network_database_never_reports_success_for_a_reclaimed_forgotten_ let target_id = Identifier::from([0x99u8; 32]); ctx.insert_local_qualified_identity(&keyed_qualified_identity(target_id, [0x98u8; 32]), &None) .expect("persist target identity"); - ctx.db() - .record_forgotten_identity(Network::Testnet, &target_id) + ctx.record_forgotten_identity(&target_id) .expect("mark the target forgotten while it is still indexed"); let blocking_claims: Vec<_> = blocked_ids diff --git a/src/database/forgotten_identities.rs b/src/database/forgotten_identities.rs deleted file mode 100644 index da4254294..000000000 --- a/src/database/forgotten_identities.rs +++ /dev/null @@ -1,147 +0,0 @@ -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::platform::Identifier; -use rusqlite::{Connection, params}; - -use super::Database; - -impl Database { - /// Create the durable per-network identity-unload marker table. - pub(crate) fn initialize_forgotten_identities_table(conn: &Connection) -> rusqlite::Result<()> { - conn.execute( - "CREATE TABLE IF NOT EXISTS forgotten_identities ( - network TEXT NOT NULL, - identity_id BLOB NOT NULL CHECK (length(identity_id) = 32), - PRIMARY KEY (network, identity_id) - )", - [], - )?; - Ok(()) - } - - /// Record that automatic discovery must not restore an unloaded identity. - pub(crate) fn record_forgotten_identity( - &self, - network: Network, - identity_id: &Identifier, - ) -> rusqlite::Result<()> { - self.execute( - "INSERT OR IGNORE INTO forgotten_identities (network, identity_id) - VALUES (?1, ?2)", - params![network.to_string(), identity_id.to_buffer()], - )?; - Ok(()) - } - - /// Allow discovery after the user explicitly restores an identity. - pub(crate) fn clear_forgotten_identity( - &self, - network: Network, - identity_id: &Identifier, - ) -> rusqlite::Result<()> { - self.execute( - "DELETE FROM forgotten_identities - WHERE network = ?1 AND identity_id = ?2", - params![network.to_string(), identity_id.to_buffer()], - )?; - Ok(()) - } - - /// List every identity deliberately unloaded on one network. - pub(crate) fn list_forgotten_identities( - &self, - network: Network, - ) -> rusqlite::Result> { - let conn = self.locked_conn(); - let mut statement = conn.prepare( - "SELECT identity_id FROM forgotten_identities - WHERE network = ?1", - )?; - let rows = statement.query_map(params![network.to_string()], |row| { - let bytes: Vec = row.get(0)?; - let id: [u8; 32] = bytes.as_slice().try_into().map_err(|source| { - rusqlite::Error::FromSqlConversionFailure( - 0, - rusqlite::types::Type::Blob, - Box::new(source), - ) - })?; - Ok(Identifier::from(id)) - })?; - rows.collect() - } - - /// Whether an identity is deliberately unloaded on one network. - pub(crate) fn is_identity_forgotten( - &self, - network: Network, - identity_id: &Identifier, - ) -> rusqlite::Result { - let conn = self.locked_conn(); - conn.query_row( - "SELECT EXISTS( - SELECT 1 FROM forgotten_identities - WHERE network = ?1 AND identity_id = ?2 - )", - params![network.to_string(), identity_id.to_buffer()], - |row| row.get(0), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::test_helpers::create_test_database; - - #[test] - fn forgotten_identity_markers_are_durable_per_network() { - let db = create_test_database().expect("create database"); - let identity_id = Identifier::from([0x31; 32]); - - db.record_forgotten_identity(Network::Testnet, &identity_id) - .expect("record marker"); - - assert!( - db.is_identity_forgotten(Network::Testnet, &identity_id) - .expect("read testnet marker") - ); - assert!( - !db.is_identity_forgotten(Network::Mainnet, &identity_id) - .expect("read mainnet marker") - ); - - db.clear_forgotten_identity(Network::Testnet, &identity_id) - .expect("clear marker"); - assert!( - !db.is_identity_forgotten(Network::Testnet, &identity_id) - .expect("read cleared marker") - ); - } - - #[test] - fn forgotten_identity_listing_is_network_scoped() { - let db = create_test_database().expect("create database"); - let testnet_ids = [Identifier::from([0x41; 32]), Identifier::from([0x42; 32])]; - let mainnet_id = Identifier::from([0x43; 32]); - - for identity_id in &testnet_ids { - db.record_forgotten_identity(Network::Testnet, identity_id) - .expect("record testnet marker"); - } - db.record_forgotten_identity(Network::Mainnet, &mainnet_id) - .expect("record mainnet marker"); - - let mut listed = db - .list_forgotten_identities(Network::Testnet) - .expect("list testnet markers"); - listed.sort_unstable(); - let mut expected = testnet_ids.to_vec(); - expected.sort_unstable(); - - assert_eq!(listed, expected); - assert!( - !listed.contains(&mainnet_id), - "identities from another network must not be returned" - ); - } -} diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 413139708..1082a2b0d 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -35,7 +35,7 @@ impl MigrationResultExt for rusqlite::Result { } } -pub const DEFAULT_DB_VERSION: u16 = 39; +pub const DEFAULT_DB_VERSION: u16 = 38; /// Minimal view of `.env` values the v34 migration needs. struct V34EnvSnapshot { @@ -239,12 +239,6 @@ impl Database { data_dir: Option<&Path>, ) -> Result<(), MigrationError> { match version { - 39 => { - Self::initialize_forgotten_identities_table(tx).migration_err( - "forgotten_identities", - "v39: create forgotten identity markers", - )?; - } 38 => { // Drop the retired `core_backend_mode` settings column. The // RPC/SPV backend selector it held was unwired in C3 (user @@ -748,8 +742,8 @@ impl Database { /// are created. Truly-fresh DET installs pass `false` so these dormant /// schemas never appear in `data.db`; legacy installs and the migration /// ladder still pass `true` so upgrade arms keep working. Always-present - /// tables (`settings`, `forgotten_identities`, - /// `platform_address_balances`) are created regardless. + /// tables (`settings`, `platform_address_balances`) are created + /// regardless. pub(crate) fn create_tables(&self, include_legacy: bool) -> rusqlite::Result<()> { let conn = self.locked_conn(); // Create the settings table. @@ -767,7 +761,6 @@ impl Database { )", [], )?; - Self::initialize_forgotten_identities_table(&conn)?; if include_legacy { // Create the wallet table @@ -3132,49 +3125,6 @@ mod test { } } - mod v39 { - #[test] - fn v39_creates_forgotten_identity_markers_for_existing_databases() { - let tmp = tempfile::tempdir().unwrap(); - let db = super::super::Database::new(tmp.path().join("v38.db")).unwrap(); - db.execute( - "CREATE TABLE settings ( - id INTEGER PRIMARY KEY CHECK (id = 1), - database_version INTEGER NOT NULL - )", - [], - ) - .unwrap(); - db.execute( - "INSERT INTO settings (id, database_version) VALUES (1, 38)", - [], - ) - .unwrap(); - assert!( - !db.table_exists(&db.locked_conn(), "forgotten_identities") - .unwrap() - ); - - db.try_perform_migration(38, 39, None).unwrap(); - - assert_eq!(db.db_schema_version().unwrap(), 39); - assert!( - db.table_exists(&db.locked_conn(), "forgotten_identities") - .unwrap() - ); - let columns: i64 = db - .locked_conn() - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('forgotten_identities') - WHERE name IN ('network', 'identity_id')", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(columns, 2); - } - } - // ---------- T-DEV-01: legacy CREATE TABLE gating ---------- /// Helper: assert that a table does NOT exist in the database. diff --git a/src/database/mod.rs b/src/database/mod.rs index b0b163c59..ffa200211 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,7 +1,6 @@ mod initialization; #[cfg(test)] pub(crate) use initialization::DEFAULT_DB_VERSION; -mod forgotten_identities; pub(crate) mod legacy_import; mod settings; mod single_key_wallet; From 9cfb21e1c60fc91b2b8828bd5d90eddf6b5fc308 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:53:26 +0000 Subject: [PATCH 39/46] fix(identity): give each forgotten-identity marker its own key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker introduced in e72fc8edd held every unloaded identity for the network in one `det:forgotten_identities:v1` set. `DetKv` takes the persister lock per call, so get-then-put is not atomic and two identities racing on that one key lose an update either way round: T1 reads {A}, T2 reads {A}, T1 writes {} (clearing A), T2 writes {A,B} (recording B) -> A's cleared marker is resurrected. Reverse the order and B's marker is destroyed instead: the unload reports success and #889 is back. The per-row SQL table this replaced was immune — independent rows do not collide — so the shared set was a regression, and precisely the bug class this PR exists to close. Each marker is now its own Global key, `det:forgotten_identity:`, presence-only (`()`) and enumerated by prefix scan — the pattern already used by `det:contract:` and `det:avatar:`. Reads and writes touch exactly one identity's key, so nothing to interleave and nothing to lose. Two further consequences: a damaged marker no longer disables the discovery guard for every other identity, and the discovery check is a single-key lookup instead of decoding the whole set per identity. Also in this pass: - Correct the `kv.rs` module doc, which claimed a `:` prefix is mandatory for all global slots. It is mandatory only for the cross-network `det-app.sqlite`; keys in the per-network `platform-wallet.sqlite` omit it, as `det:identity_index:v1` and ~9 siblings already do. The overbroad wording is what made this key's naming look wrong on review. - Give the persister fault-injection test handles the 5s `busy_timeout` the real persister runs with, via one shared `test_support::open_persister_fault_connection` helper instead of four bare `Connection::open` calls that inherited none of its settings. The race test is a real regression test, not decoration: reverting the implementation to the shared set under it reproduces the reported failure ("a concurrent record was lost"), and the per-key version passed 9/9 consecutive runs. Co-Authored-By: Claude Opus 5 --- docs/kv-keys.md | 6 +- .../identity/discover_identities.rs | 5 +- src/backend_task/identity/load_guard.rs | 8 +- src/backend_task/identity/load_identity.rs | 5 +- src/context/identity_db.rs | 296 +++++++++++++----- src/context/test_support.rs | 23 ++ src/wallet_backend/kv.rs | 12 +- 7 files changed, 256 insertions(+), 99 deletions(-) diff --git a/docs/kv-keys.md b/docs/kv-keys.md index 6ed082f30..9b93c9abf 100644 --- a/docs/kv-keys.md +++ b/docs/kv-keys.md @@ -82,14 +82,16 @@ Source: `src/model/selected_wallet.rs`, `src/wallet_backend/mod.rs` The identity blob and top-up history are **identity-scoped** (`DetScope::Identity(&id)`) so the upstream soft-cascade reaps them when the identity row is deleted. `DetScope::Identity` has no cross-identity listing, so a Global `det:identity_index:v1` slot holds the complete id roster the load-all paths iterate. `det:identity_order:v1` is a separate user-ordering view (may lag the full set) and stays Global. -`det:forgotten_identities:v1` is Global for the opposite reason to the blob: the marker's whole purpose is to outlive the identity it names, so automatic discovery cannot resurrect a deliberate unload. An identity-scoped slot would be reaped by that same soft-cascade at exactly the moment the marker becomes load-bearing. Like the other Global identity keys it is per-network by virtue of the per-network store, not by anything in the key or the value. +`det:forgotten_identity:` is Global for the opposite reason to the blob: the marker's whole purpose is to outlive the identity it names, so automatic discovery cannot resurrect a deliberate unload. An identity-scoped slot would be reaped by that same soft-cascade at exactly the moment the marker becomes load-bearing. Like the other Global identity keys it is per-network by virtue of the per-network store, not by anything in the key or the value. + +The markers are **one key per identity**, enumerated by prefix scan, rather than a single slot holding the whole set. `DetKv` takes the persister lock per call, so a shared collection would make every marker write an unguarded read-modify-write: two identities unloading concurrently would clobber each other, either dropping a marker (the unload silently forgotten) or resurrecting a cleared one. Independent keys collide no more than the per-row SQL table this replaced, and confine a damaged marker to the one identity it names. | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| | `det:identity:v1` | `DetScope::Identity(&id)` | `platform-wallet.sqlite` | `StoredQualifiedIdentity` | Fields: `qi_bytes` (inner bincode, redacted in `Debug`), `status: u8`, `identity_type: String`, `wallet_hash: Option<[u8;32]>`, `wallet_index: Option` | | `det:identity_index:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | Complete enumeration index of stored identity ids | | `det:identity_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | User-chosen display ordering of identity ID raw bytes | -| `det:forgotten_identities:v1` | `None` | `platform-wallet.sqlite` | `BTreeSet<[u8;32]>` | Identities the user deliberately unloaded; discovery must not restore them. Retiring the last marker deletes the slot | +| `det:forgotten_identity:` | `None` | `platform-wallet.sqlite` | `()` | Presence-only flag: the user deliberately unloaded this identity and discovery must not restore it. One key per identity | | `det:top_ups:v1` | `DetScope::Identity(&id)` | `platform-wallet.sqlite` | `BTreeMap` | Top-up history: account index → credits | Source: `src/context/identity_db.rs` diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index 45d17b161..c31397d2b 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -625,13 +625,12 @@ mod tests { ctx.record_forgotten_identity(&identity_id) .expect("record forgotten marker"); let fault_connection = - rusqlite::Connection::open(backend.spv_storage_dir().join("platform-wallet.sqlite")) - .expect("open persister second handle"); + crate::context::test_support::open_persister_fault_connection(&backend); fault_connection .execute_batch( "CREATE TRIGGER fail_discovery_marker_cleanup BEFORE DELETE ON meta_global - WHEN OLD.key = 'det:forgotten_identities:v1' + WHEN OLD.key LIKE 'det:forgotten_identity:%' BEGIN SELECT RAISE(FAIL, 'injected discovery marker cleanup failure'); END;", diff --git a/src/backend_task/identity/load_guard.rs b/src/backend_task/identity/load_guard.rs index 86327e3be..47ae5e61b 100644 --- a/src/backend_task/identity/load_guard.rs +++ b/src/backend_task/identity/load_guard.rs @@ -37,7 +37,7 @@ impl AppContext { mod tests { use super::*; use crate::context::identity_load_registry::IdentityLoadPhase; - use crate::context::test_support::test_app_context; + use crate::context::test_support::{open_persister_fault_connection, test_app_context}; use dash_sdk::dpp::identity::Purpose; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, @@ -97,14 +97,12 @@ mod tests { let identity_id = Identifier::from([0x72; 32]); ctx.record_forgotten_identity(&identity_id) .expect("record forgotten marker"); - let fault_connection = - rusqlite::Connection::open(backend.spv_storage_dir().join("platform-wallet.sqlite")) - .expect("open persister second handle"); + let fault_connection = open_persister_fault_connection(&backend); fault_connection .execute_batch( "CREATE TRIGGER fail_forgotten_marker_cleanup BEFORE DELETE ON meta_global - WHEN OLD.key = 'det:forgotten_identities:v1' + WHEN OLD.key LIKE 'det:forgotten_identity:%' BEGIN SELECT RAISE(FAIL, 'injected forgotten marker cleanup failure'); END;", diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 246d312e9..a3f0f47c7 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -1649,13 +1649,12 @@ mod tests { ctx.record_forgotten_identity(&identity_id) .expect("record forgotten marker"); let fault_connection = - rusqlite::Connection::open(backend.spv_storage_dir().join("platform-wallet.sqlite")) - .expect("open persister second handle"); + crate::context::test_support::open_persister_fault_connection(&backend); fault_connection .execute_batch( "CREATE TRIGGER fail_load_marker_cleanup BEFORE DELETE ON meta_global - WHEN OLD.key = 'det:forgotten_identities:v1' + WHEN OLD.key LIKE 'det:forgotten_identity:%' BEGIN SELECT RAISE(FAIL, 'injected load marker cleanup failure'); END;", diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index b8154ec96..c019401d2 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -11,7 +11,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; /// Identity blob slot, scoped to [`DetScope::Identity`]. One entry per @@ -33,12 +33,22 @@ const IDENTITY_ORDER_KEY: &str = "det:identity_order:v1"; /// full set. const IDENTITY_INDEX_KEY: &str = "det:identity_index:v1"; -/// Global set of identities the user deliberately unloaded, which automatic -/// discovery must not restore. Lives in [`DetScope::Global`] because the marker -/// has to outlive the identity it names: a [`DetScope::Identity`] slot is reaped -/// by the upstream soft-cascade at exactly the moment the marker becomes load- -/// bearing. Per-network by virtue of the store, like [`IDENTITY_INDEX_KEY`]. -const FORGOTTEN_IDENTITIES_KEY: &str = "det:forgotten_identities:v1"; +/// "Deliberately unloaded" marker, one Global key per identity. The full key +/// is `det:forgotten_identity:`; presence is the whole signal, so +/// the value is empty. Enumerated by prefix scan. +/// +/// One key per identity rather than one shared set: the adapter takes the +/// persister lock per call, so a shared collection would turn every marker +/// write into an unguarded read-modify-write and let two identities' unloads +/// clobber each other. Independent keys collide no more than the per-row SQL +/// table this replaced. +/// +/// [`DetScope::Global`] rather than [`DetScope::Identity`] because the marker +/// has to outlive the identity it names: an identity-scoped slot is reaped by +/// the upstream soft-cascade at exactly the moment the marker becomes +/// load-bearing. Per-network by virtue of the store, like +/// [`IDENTITY_INDEX_KEY`]. +const FORGOTTEN_IDENTITY_KEY_PREFIX: &str = "det:forgotten_identity:"; /// Scheduled-vote slot key, scoped to [`DetScope::Identity`] of the /// voter. The full key is `det:scheduled_vote:` — the @@ -286,49 +296,56 @@ fn index_remove_identity(kv: &DetKv, identity_id: &[u8; 32]) -> std::result::Res .map_err(identity_err) } -/// Read the Global forgotten-identity marker set. Returns an empty set -/// when nothing on this network has ever been deliberately unloaded. -fn load_forgotten_identities(kv: &DetKv) -> std::result::Result, TaskError> { - Ok(kv - .get::>(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) - .map_err(forgotten_err)? - .unwrap_or_default()) +/// The marker key naming `identity_id`. +fn forgotten_identity_key(identity_id: &Identifier) -> String { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + format!( + "{FORGOTTEN_IDENTITY_KEY_PREFIX}{}", + identity_id.to_string(Encoding::Base58) + ) } -/// Mark `identity_id` as deliberately unloaded. No-op when it is already -/// marked, so repeated unload attempts stay idempotent. -fn forgotten_add_identity( +/// Recover the identity a marker key names. `None` when the suffix is not a +/// valid identifier — a damaged or foreign key sharing the prefix, which the +/// enumeration skips rather than failing the whole listing over. +fn forgotten_identity_from_key(key: &str) -> Option { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let suffix = key.strip_prefix(FORGOTTEN_IDENTITY_KEY_PREFIX)?; + Identifier::from_string(suffix, Encoding::Base58).ok() +} + +/// Mark `identity_id` as deliberately unloaded. Idempotent — the write is a +/// blind upsert of a presence-only key, so it neither reads nor rewrites any +/// other identity's marker. +fn forgotten_marker_put( kv: &DetKv, - identity_id: &[u8; 32], + identity_id: &Identifier, ) -> std::result::Result<(), TaskError> { - let mut forgotten = load_forgotten_identities(kv)?; - if !forgotten.insert(*identity_id) { - return Ok(()); - } - kv.put(DetScope::Global, FORGOTTEN_IDENTITIES_KEY, &forgotten) + kv.put::<()>(DetScope::Global, &forgotten_identity_key(identity_id), &()) .map_err(forgotten_err) } -/// Drop `identity_id`'s marker. No-op when it is not marked. Retiring the -/// last marker removes the slot outright rather than leaving an empty set -/// behind, so a device with nothing forgotten carries no marker record. -fn forgotten_remove_identity( +/// Drop `identity_id`'s marker. Idempotent — deleting an absent key is `Ok`. +fn forgotten_marker_delete( kv: &DetKv, - identity_id: &[u8; 32], + identity_id: &Identifier, ) -> std::result::Result<(), TaskError> { - let mut forgotten = load_forgotten_identities(kv)?; - if !forgotten.remove(identity_id) { - return Ok(()); - } - if forgotten.is_empty() { - return kv - .delete(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) - .map_err(forgotten_err); - } - kv.put(DetScope::Global, FORGOTTEN_IDENTITIES_KEY, &forgotten) + kv.delete(DetScope::Global, &forgotten_identity_key(identity_id)) .map_err(forgotten_err) } +/// Whether `identity_id` carries a marker. A damaged marker fails only this +/// identity's check; every other identity's guard is unaffected. +fn forgotten_marker_exists( + kv: &DetKv, + identity_id: &Identifier, +) -> std::result::Result { + Ok(kv + .get::<()>(DetScope::Global, &forgotten_identity_key(identity_id)) + .map_err(forgotten_err)? + .is_some()) +} + /// Delete every Identity-scoped child of `id` (blob, top-up history, all /// scheduled votes) and prune the scheduled-vote voter index. Does not /// touch the Global identity index — callers decide whether to drop the @@ -1025,7 +1042,7 @@ impl AppContext { &self, identifier: &Identifier, ) -> std::result::Result<(), TaskError> { - forgotten_add_identity(&self.det_kv()?, &identifier.to_buffer()) + forgotten_marker_put(&self.det_kv()?, identifier) } /// Whether automatic discovery must leave this identity unloaded. @@ -1033,16 +1050,28 @@ impl AppContext { &self, identifier: &Identifier, ) -> std::result::Result { - Ok(load_forgotten_identities(&self.det_kv()?)?.contains(&identifier.to_buffer())) + forgotten_marker_exists(&self.det_kv()?, identifier) } - /// Every identity deliberately unloaded on this network. + /// Every identity deliberately unloaded on this network, read by prefix + /// scan. A marker key whose suffix does not decode is skipped with a + /// warning rather than failing the sweep that consumes this list. pub(crate) fn list_forgotten_identities( &self, ) -> std::result::Result, TaskError> { - Ok(load_forgotten_identities(&self.det_kv()?)? + let kv = self.det_kv()?; + let keys = kv + .list(DetScope::Global, Some(FORGOTTEN_IDENTITY_KEY_PREFIX)) + .map_err(forgotten_err)?; + Ok(keys .into_iter() - .map(Identifier::from) + .filter_map(|key| { + let identity_id = forgotten_identity_from_key(&key); + if identity_id.is_none() { + tracing::warn!(key = %key, "Skipping unreadable forgotten-identity marker"); + } + identity_id + }) .collect()) } @@ -1051,7 +1080,7 @@ impl AppContext { &self, identifier: &Identifier, ) -> std::result::Result<(), TaskError> { - forgotten_remove_identity(&self.det_kv()?, &identifier.to_buffer()) + forgotten_marker_delete(&self.det_kv()?, identifier) } /// Finish cleanup for a forgotten, unindexed identity whose blob remains. @@ -1102,7 +1131,7 @@ impl AppContext { self.cleanup_identity_after_index_removal(&identifier)?; if clear_forgotten_marker { - forgotten_remove_identity(&self.det_kv()?, id)?; + forgotten_marker_delete(&self.det_kv()?, &identifier)?; } Ok(true) } @@ -1228,7 +1257,8 @@ impl AppContext { // `purge_identity_scope`, since it reads the identity blob that // `purge_identity_scope` deletes. if let Err(error) = index_remove_identity(&kv, &id) { - if remember_unload && let Err(rollback_error) = forgotten_remove_identity(&kv, &id) { + if remember_unload && let Err(rollback_error) = forgotten_marker_delete(&kv, identifier) + { tracing::warn!( identity_id = %identifier, original_error = ?error, @@ -1846,24 +1876,26 @@ mod tests { // unload. // --------------------------------------------------------------- + fn identifier(b: u8) -> Identifier { + Identifier::from(id(b)) + } + #[test] fn forgotten_marker_round_trips_and_is_per_identity() { let kv = empty_kv(); - assert!(load_forgotten_identities(&kv).unwrap().is_empty()); + assert!(!forgotten_marker_exists(&kv, &identifier(1)).unwrap()); - forgotten_add_identity(&kv, &id(1)).unwrap(); - forgotten_add_identity(&kv, &id(2)).unwrap(); + forgotten_marker_put(&kv, &identifier(1)).unwrap(); + forgotten_marker_put(&kv, &identifier(2)).unwrap(); - let forgotten = load_forgotten_identities(&kv).unwrap(); - assert!(forgotten.contains(&id(1))); - assert!(forgotten.contains(&id(2))); - assert!(!forgotten.contains(&id(3))); + assert!(forgotten_marker_exists(&kv, &identifier(1)).unwrap()); + assert!(forgotten_marker_exists(&kv, &identifier(2)).unwrap()); + assert!(!forgotten_marker_exists(&kv, &identifier(3)).unwrap()); - forgotten_remove_identity(&kv, &id(1)).unwrap(); - let forgotten = load_forgotten_identities(&kv).unwrap(); - assert!(!forgotten.contains(&id(1))); + forgotten_marker_delete(&kv, &identifier(1)).unwrap(); + assert!(!forgotten_marker_exists(&kv, &identifier(1)).unwrap()); assert!( - forgotten.contains(&id(2)), + forgotten_marker_exists(&kv, &identifier(2)).unwrap(), "clearing one marker keeps the rest" ); } @@ -1871,26 +1903,50 @@ mod tests { #[test] fn forgotten_marker_writes_are_idempotent() { let kv = empty_kv(); - forgotten_add_identity(&kv, &id(1)).unwrap(); - forgotten_add_identity(&kv, &id(1)).unwrap(); - assert_eq!(load_forgotten_identities(&kv).unwrap().len(), 1); + forgotten_marker_put(&kv, &identifier(1)).unwrap(); + forgotten_marker_put(&kv, &identifier(1)).unwrap(); + assert_eq!( + kv.list(DetScope::Global, Some(FORGOTTEN_IDENTITY_KEY_PREFIX)) + .unwrap() + .len(), + 1 + ); - forgotten_remove_identity(&kv, &id(1)).unwrap(); - forgotten_remove_identity(&kv, &id(1)).unwrap(); - forgotten_remove_identity(&kv, &id(9)).unwrap(); - assert!(load_forgotten_identities(&kv).unwrap().is_empty()); + forgotten_marker_delete(&kv, &identifier(1)).unwrap(); + forgotten_marker_delete(&kv, &identifier(1)).unwrap(); + forgotten_marker_delete(&kv, &identifier(9)).unwrap(); + assert!( + kv.list(DetScope::Global, Some(FORGOTTEN_IDENTITY_KEY_PREFIX)) + .unwrap() + .is_empty() + ); } + /// Each identity owns a private key, so no marker write ever reads or + /// rewrites another identity's marker. This is what makes concurrent + /// unloads safe; a shared collection would reintroduce the lost update. #[test] - fn retiring_the_last_forgotten_marker_removes_the_slot() { + fn each_forgotten_marker_is_an_independent_key() { let kv = empty_kv(); - forgotten_add_identity(&kv, &id(1)).unwrap(); - forgotten_remove_identity(&kv, &id(1)).unwrap(); - assert!( - kv.get::>(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) - .unwrap() - .is_none(), - "an emptied marker set must leave no residual slot behind" + forgotten_marker_put(&kv, &identifier(1)).unwrap(); + forgotten_marker_put(&kv, &identifier(2)).unwrap(); + + let keys = kv + .list(DetScope::Global, Some(FORGOTTEN_IDENTITY_KEY_PREFIX)) + .unwrap(); + assert_eq!(keys.len(), 2, "two identities must occupy two keys"); + assert!(keys.contains(&forgotten_identity_key(&identifier(1)))); + assert!(keys.contains(&forgotten_identity_key(&identifier(2)))); + + // A marker key round-trips through its own encoding. + assert_eq!( + forgotten_identity_from_key(&forgotten_identity_key(&identifier(1))), + Some(identifier(1)) + ); + // A key that merely shares the prefix is skipped, not fatal. + assert_eq!( + forgotten_identity_from_key(&format!("{FORGOTTEN_IDENTITY_KEY_PREFIX}not-base58!")), + None ); } @@ -1901,13 +1957,13 @@ mod tests { fn forgotten_marker_outlives_its_identity_scope() { let kv = empty_kv(); put_identity(&kv, &id(1), "User"); - forgotten_add_identity(&kv, &id(1)).unwrap(); + forgotten_marker_put(&kv, &identifier(1)).unwrap(); purge_identity_scope(&kv, &id(1)).unwrap(); index_remove_identity(&kv, &id(1)).unwrap(); assert!( - load_forgotten_identities(&kv).unwrap().contains(&id(1)), + forgotten_marker_exists(&kv, &identifier(1)).unwrap(), "purging every identity-scoped record must not touch the marker" ); assert!( @@ -1944,17 +2000,18 @@ mod tests { ctx.list_forgotten_identities().expect("list markers"), vec![identity_id] ); + let marker_key = forgotten_identity_key(&identity_id); assert!( ctx.det_kv() .expect("per-network k/v") - .get::>(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) + .get::<()>(DetScope::Global, &marker_key) .expect("read per-network marker slot") .is_some(), "the marker belongs to the per-network wallet store" ); assert!( ctx.app_kv() - .get::>(DetScope::Global, FORGOTTEN_IDENTITIES_KEY) + .get::<()>(DetScope::Global, &marker_key) .expect("read cross-network marker slot") .is_none(), "the cross-network app store must hold no marker" @@ -1971,6 +2028,82 @@ mod tests { backend.shutdown().await; } + /// Concurrent marker work on *different* identities must not lose an + /// update. Records and clears run interleaved against the real persister, + /// so any read-modify-write over a shared blob would drop a marker one way + /// (an unload silently forgotten, #889 back) or resurrect one the other + /// (a cleared identity kept unloaded). + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_marker_writes_for_distinct_identities_do_not_clobber() { + use crate::app::TaskResult; + use crate::context::test_support::test_app_context; + use crate::utils::egui_mpsc::SenderAsync; + + const IDENTITIES: u8 = 12; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(temp_dir.path()); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + let backend = ctx.wallet_backend().expect("wallet backend"); + + // Half start marked and get cleared; half start clear and get marked. + // Both directions run at once so a lost update in either direction + // shows up as a wrong final state. + let to_record: Vec = (0..IDENTITIES).map(identifier).collect(); + let to_clear: Vec = (100..100 + IDENTITIES).map(identifier).collect(); + for identity_id in &to_clear { + ctx.record_forgotten_identity(identity_id) + .expect("seed a marker the race will clear"); + } + + // `(identity, is_clear)` work items, owned so each task can take one. + let mut work: Vec<(Identifier, bool)> = to_record.iter().map(|id| (*id, false)).collect(); + work.extend(to_clear.iter().map(|id| (*id, true))); + + let mut tasks = tokio::task::JoinSet::new(); + for (identity_id, clearing) in work { + let ctx = Arc::clone(&ctx); + tasks.spawn(async move { + if clearing { + ctx.clear_forgotten_identity_after_explicit_load(&identity_id) + .expect("concurrent marker clear"); + } else { + ctx.record_forgotten_identity(&identity_id) + .expect("concurrent marker record"); + } + }); + } + while let Some(joined) = tasks.join_next().await { + joined.expect("no marker task may panic"); + } + + for identity_id in &to_record { + assert!( + ctx.is_identity_forgotten(identity_id) + .expect("read recorded marker"), + "a concurrent record was lost for {identity_id}" + ); + } + for identity_id in &to_clear { + assert!( + !ctx.is_identity_forgotten(identity_id) + .expect("read cleared marker"), + "a concurrent clear was resurrected for {identity_id}" + ); + } + let mut listed = ctx.list_forgotten_identities().expect("list markers"); + listed.sort_unstable(); + let mut expected = to_record; + expected.sort_unstable(); + assert_eq!(listed, expected, "the surviving marker set must be exact"); + + backend.shutdown().await; + } + // --------------------------------------------------------------- // Identity-type tag: writer and filter share one stable mapping. // --------------------------------------------------------------- @@ -2795,9 +2928,8 @@ mod tests { ctx.insert_local_qualified_identity(&target, &Some(([0xBC; 32], 2))) .expect("insert target identity"); - let persister_path = backend.spv_storage_dir().join("platform-wallet.sqlite"); let fault_connection = - rusqlite::Connection::open(&persister_path).expect("open persister second handle"); + crate::context::test_support::open_persister_fault_connection(&backend); fault_connection .execute_batch( "CREATE TRIGGER fail_identity_index_commit @@ -2808,14 +2940,14 @@ mod tests { END;", ) .expect("install identity-index trigger"); - // The marker write is an upsert and its rollback a delete, so the two - // halves of the unload can be faulted independently: recording the - // marker still succeeds, only rolling it back fails. + // Recording the marker is a write and rolling it back a delete, so the + // two halves of the unload fault independently: the marker still gets + // recorded, only its rollback fails. fault_connection .execute_batch( "CREATE TRIGGER fail_forgotten_marker_rollback BEFORE DELETE ON meta_global - WHEN OLD.key = 'det:forgotten_identities:v1' + WHEN OLD.key LIKE 'det:forgotten_identity:%' BEGIN SELECT RAISE(FAIL, 'injected marker-rollback failure'); END;", diff --git a/src/context/test_support.rs b/src/context/test_support.rs index fb6cd0bf3..504120527 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -40,3 +40,26 @@ pub(crate) fn test_app_context_with_kv(dir: &Path, app_kv: Arc) -> Arc rusqlite::Connection { + let connection = + rusqlite::Connection::open(backend.spv_storage_dir().join("platform-wallet.sqlite")) + .expect("open persister second handle"); + connection + .busy_timeout(PERSISTER_BUSY_TIMEOUT) + .expect("match the persister's busy timeout"); + connection +} diff --git a/src/wallet_backend/kv.rs b/src/wallet_backend/kv.rs index 43f1cd062..d26a0a04d 100644 --- a/src/wallet_backend/kv.rs +++ b/src/wallet_backend/kv.rs @@ -18,10 +18,14 @@ //! `private` / `address_index` overlays are all identity-scoped. //! //! All keys carried by this adapter follow a colon-separated namespace -//! convention, with a mandatory `:` prefix for global slots so -//! mainnet / testnet / devnet entries cannot collide inside the same -//! upstream database file. See the documentation on the consumer -//! callers (e.g. settings storage) for the canonical key schema. +//! convention. Whether a key needs a `:` prefix depends on the +//! store behind it, not on the scope: entries in the cross-network +//! `det-app.sqlite` (wallet-meta and single-key sidecars, migration +//! sentinels) share one file across every network and must carry the +//! prefix so mainnet / testnet / devnet cannot collide. Entries in the +//! per-network `spv//platform-wallet.sqlite` get one file per +//! network already, so they omit it — see `det:identity_index:v1` and +//! its siblings. `docs/kv-keys.md` catalogues every key and its store. //! //! ## Encoding //! From 6bd18091008d0ea170165b5a52b72cea8066b3ba Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:12:42 +0000 Subject: [PATCH 40/46] style(platform): move the test module to the end of platform_info.rs `items_after_test_module` fails `cargo clippy --all-targets -D warnings`, so the whole lint gate is red on this branch. Pure relocation: no test, function, or behaviour changes. Co-Authored-By: Claude Opus 5 --- src/backend_task/platform_info.rs | 34 +++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index e557fceda..55cf57a94 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -470,23 +470,6 @@ fn withdrawal_status_str(status: WithdrawalStatus) -> &'static str { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn epoch_workaround_reports_protocol_version_and_stale_fee_cache() { - assert_eq!( - format_unavailable_current_epoch_info(12), - "Current Epoch Information:\n\ - • Protocol Version: 12\n\ - • Epoch details and fee multiplier are temporarily unavailable while \ - dashpay/platform#4231 is unresolved.\n\n\ - (The fee multiplier cache was not updated.)" - ); - } -} - /// Flatten one withdrawal [`Document`] into a [`WithdrawalRecord`]. fn extract_withdrawal_record( document: &Document, @@ -934,3 +917,20 @@ impl AppContext { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epoch_workaround_reports_protocol_version_and_stale_fee_cache() { + assert_eq!( + format_unavailable_current_epoch_info(12), + "Current Epoch Information:\n\ + • Protocol Version: 12\n\ + • Epoch details and fee multiplier are temporarily unavailable while \ + dashpay/platform#4231 is unresolved.\n\n\ + (The fee multiplier cache was not updated.)" + ); + } +} From 71133079a4711c7f45ac9fb903c6d33580b6718f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:12:57 +0000 Subject: [PATCH 41/46] fix(identity): consistent unload confirmations, removal feedback, and unload durability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the masternode identity lifecycle, all in the same unload/removal path. Confirmations could not stay in step. The Identity Hub, the Identities list, and the masternode detail view each built their own dialog: the list offered "Yes"/"No" on an action that deletes private keys, and neither Remove dialog blocked input to the screen behind it — both against docs/ux-design-patterns.md §4. `identity_unload_confirmation_dialog` and `identity_removal_confirmation_dialog` now build all three, deriving title and verbs from the identity kind so no call site supplies a label; the masternode verb stays the copy its spec pins it to. Removal reported its outcome on one screen only. `RemoveIdentity` returns three cleanup flags, and only the Identities list turned them into a banner — removing a node from the Masternodes tab looked identical whether cleanup succeeded or left owner/voter keys on disk. The banner moves into `AppState`'s result match, so every dispatcher reports the outcome by construction, and a removal with residue keeps its warning on screen until dismissed. A wallet-wide search un-forgot every identity it re-derived. The forgotten marker was gated on discovery *mode*, so "search this wallet up to index N" restored, re-persisted, and un-forgot every unloaded identity it happened to find — the user consented to none of them. A scan names no identity, so no scan clears a marker; restoring is left to the loads that do name one (the By-Wallet specific index, or a load by id). Identities left alone are counted and reported, so a smaller result is not mistaken for a failure. Co-Authored-By: Claude Opus 5 --- docs/user-stories.md | 10 +- src/app.rs | 41 ++- .../identity/discover_identities.rs | 216 +++++---------- .../identity/load_identity_from_wallet.rs | 18 +- src/backend_task/mod.rs | 4 + src/model/identity_discovery.rs | 5 + .../add_existing_identity_screen.rs | 57 +++- src/ui/identities/identities_screen.rs | 126 +-------- src/ui/identity/settings.rs | 259 +++++++++++++++++- src/ui/masternodes/detail_screen.rs | 18 +- tests/kittest/identities_screen.rs | 79 +++++- tests/kittest/masternode_tab.rs | 39 +++ 12 files changed, 568 insertions(+), 304 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index 455987742..593eee5a1 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -652,6 +652,7 @@ As a user, I want my wallet's identities to be found and loaded automatically on - The search uses a rolling five-index lookahead, going deeper each time an identity is found, so identities at non-contiguous indices are discovered. - Already-loaded identities are refreshed (new keys, new DPNS names) while any alias the user assigned is preserved. - Locked, password-protected wallets are skipped without prompting; they are searched after the user unlocks them. +- No search of a whole wallet — automatic or user-started — brings back an identity the user unloaded. Those are left alone, and the result reports how many, so a smaller count is not mistaken for a failure. ### IDN-016: Identities and their keys preserved across an app upgrade [Implemented] **Persona:** Alex, Priya @@ -678,7 +679,14 @@ As a user, I want to unload one identity from this device so that I can recover scheduled votes are queued, the confirmation states how many will be cancelled. The confirmation also names the synced data that only a full database clear removes, and discloses that the app records the unload so - automatic discovery does not bring the identity back. + automatic discovery does not bring the identity back. All three + confirmations use the same danger-styled, input-blocking dialog with buttons + that name the action instead of a generic Yes/No pair. +- The recorded unload also survives a user-started search of the whole wallet; + only loading that identity by its own index (or by its ID) brings it back. +- Removal reports its outcome, whichever screen it was started from, and says + what to retry when local data or the node's voting identity could not be + cleaned up. - Unloading removes only the selected identity's local keys, metadata, its own DashPay overlay records, queued scheduled votes, and device record, while leaving the Platform identity unchanged. DashPay data held by another loaded identity that lists this one as a contact is not touched. - Other identities on the same wallet and the wallet's recovery seed remain available. diff --git a/src/app.rs b/src/app.rs index c79924cd6..d56060007 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,7 +23,9 @@ use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, Progre use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; use crate::ui::dpns::dpns_contested_names_screen::{DPNSScreen, DPNSSubscreen}; +use crate::ui::identities::add_existing_identity_screen::wallet_identity_search_message; use crate::ui::identities::identities_screen::IdentitiesScreen; +use crate::ui::identity::settings::identity_removal_message; use crate::ui::network_chooser_screen::{NetworkChooserScreen, chooser_network_label}; use crate::ui::theme::ThemeMode; use crate::ui::tokens::tokens_screen::{TokensScreen, TokensSubscreen}; @@ -2579,12 +2581,11 @@ impl App for AppState { self.visible_screen_mut() .display_backend_task_result(&context, unboxed_message); } - BackendTaskSuccessResult::IdentitiesLoaded { count } => { - let msg = if count == 1 { - "Successfully loaded 1 identity from your wallet.".to_string() - } else { - format!("Successfully loaded {count} identities from your wallet.") - }; + BackendTaskSuccessResult::IdentitiesLoaded { + count, + skipped_forgotten, + } => { + let msg = wallet_identity_search_message(count, skipped_forgotten); MessageBanner::set_global(ctx, &msg, MessageType::Success); self.visible_screen_mut() .display_backend_task_result(&context, unboxed_message); @@ -2668,6 +2669,34 @@ impl App for AppState { // without a manual Refresh. No banner — this fires every 15 s. active_context.apply_platform_address_push(updates); } + BackendTaskSuccessResult::RemovedIdentities { + primary_cleanup_failed, + associated_cleanup_failed, + associated_removal_failed, + .. + } => { + // Here rather than in a screen: the card or row + // disappearing looks the same whether cleanup + // succeeded or left keys on disk, so every screen + // that removes an identity — present and future — + // reports the outcome by construction. + let (message, message_type) = identity_removal_message( + primary_cleanup_failed, + associated_cleanup_failed, + associated_removal_failed, + ); + let banner = MessageBanner::set_global(ctx, message, message_type); + if primary_cleanup_failed + || associated_cleanup_failed + || associated_removal_failed + { + // The user has a retry to perform; a warning + // that fades out is a warning they may miss. + banner.disable_auto_dismiss(); + } + self.visible_screen_mut() + .display_backend_task_result(&context, unboxed_message); + } BackendTaskSuccessResult::TokenBalanceRefreshAlreadyInFlight => { MessageBanner::set_global( ctx, diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index c31397d2b..c468e4916 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -17,13 +17,19 @@ use std::sync::{Arc, RwLock}; const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; /// Whether discovery is automatic, follows unlock, or is user-requested. +/// +/// The mode decides only whether a cold secret-cache miss may prompt. No mode +/// restores an identity the user unloaded: a scan sweeps a whole wallet and +/// names no identity, so it can never carry consent for one. Restoring is the +/// job of a load aimed at a single identity — the By-Wallet search's specific +/// index, or a load by identity id. #[derive(Clone, Copy)] pub(crate) enum IdentityDiscoveryMode { - /// Automatic startup discovery; never prompts or restores unloaded identities. + /// Automatic startup discovery; never prompts. Background, - /// Post-unlock discovery; may use the unlocked seed but never restores identities. + /// Post-unlock discovery; may use the unlocked seed. WalletUnlock, - /// User-started search; may deliberately restore an unloaded identity. + /// User-started wallet-wide search; may prompt for the seed. ExplicitSearch, } @@ -31,10 +37,16 @@ impl IdentityDiscoveryMode { fn allow_prompt(self) -> bool { !matches!(self, Self::Background) } +} - fn explicitly_reloads_forgotten(self) -> bool { - matches!(self, Self::ExplicitSearch) - } +/// What one discovered identity contributed to the pass. +enum DiscoveredIdentityOutcome { + /// Newly inserted or refreshed in the local database. + Stored, + /// Left unloaded because the user had unloaded it. + SkippedForgotten, + /// Not stored for any other reason (e.g. the network changed mid-scan). + Skipped, } impl AppContext { @@ -49,10 +61,9 @@ impl AppContext { /// prior-session high index is never missed even if the early indices are /// empty. /// - /// `mode` controls whether a cold secret-cache miss may prompt and whether - /// the scan is an explicit user request that may restore an identity the - /// user unloaded. Startup and wallet-unlock discovery always leave forgotten - /// identities untouched. + /// `mode` controls whether a cold secret-cache miss may prompt. Whatever the + /// mode, an identity the user unloaded is left unloaded and counted in + /// [`DiscoverySummary::skipped_forgotten`]. /// /// When `progress` is `Some`, a [`BackendTaskSuccessResult::Progress`] event /// is sent before each probed index. @@ -200,8 +211,13 @@ impl AppContext { ) .await { - Ok(true) => summary.stored = summary.stored.saturating_add(1), - Ok(false) => {} + Ok(DiscoveredIdentityOutcome::Stored) => { + summary.stored = summary.stored.saturating_add(1) + } + Ok(DiscoveredIdentityOutcome::SkippedForgotten) => { + summary.skipped_forgotten = summary.skipped_forgotten.saturating_add(1) + } + Ok(DiscoveredIdentityOutcome::Skipped) => {} Err(e) => tracing::warn!( identity_id = %identity_id, error = %e, @@ -264,10 +280,10 @@ impl AppContext { scan_network: dash_sdk::dpp::dashcore::Network, mode: IdentityDiscoveryMode, identity_index: u32, - ) -> Result { + ) -> Result { if self.network != scan_network { tracing::debug!("Network changed mid-scan; skipping store of discovered identity"); - return Ok(false); + return Ok(DiscoveredIdentityOutcome::Skipped); } let identity_id = identity.id(); @@ -283,19 +299,17 @@ impl AppContext { ) .await?; - let explicit_reload = mode.explicitly_reloads_forgotten(); let Some(load_guard) = self.persist_discovered_identity( qualified_identity.clone(), seed_hash, identity_index, - explicit_reload, )? else { tracing::debug!( identity_id = %identity_id, "Skipped a discovered identity that the user unloaded" ); - return Ok(false); + return Ok(DiscoveredIdentityOutcome::SkippedForgotten); }; if let Ok(mut wallet_guard) = wallet.write() { @@ -303,37 +317,37 @@ impl AppContext { .identities .insert(identity_index, qualified_identity.identity.clone()); } - if explicit_reload { - self.finish_identity_load_after_persist(&identity_id, load_guard); - } else { - load_guard.loaded(); - } + load_guard.loaded(); tracing::info!( identity_id = %identity_id, "Successfully loaded discovered identity" ); - Ok(true) + Ok(DiscoveredIdentityOutcome::Stored) } - /// Persist one discovery result unless automatic discovery must leave it unloaded. + /// Persist one discovery result, or `Ok(None)` when the user unloaded it. /// /// A persisted result returns its exclusive load claim so the caller can - /// keep it through the corresponding wallet-cache update. + /// keep it through the corresponding wallet-cache update. Discovery never + /// overrides an unload: it derives identities from a wallet, so it cannot + /// distinguish the one identity a user wants back from every other one it + /// re-derives along the way. pub(crate) fn persist_discovered_identity( &self, mut qualified_identity: crate::model::qualified_identity::QualifiedIdentity, seed_hash: crate::model::wallet::WalletSeedHash, identity_index: u32, - explicit_reload: bool, ) -> Result, TaskError> { let identity_id = qualified_identity.identity.id(); let load_guard = self.begin_identity_load(identity_id, None)?; - if self.is_identity_forgotten(&identity_id)? && !explicit_reload { + if self.is_identity_forgotten(&identity_id)? { load_guard.loaded(); return Ok(None); } - self.prepare_stuck_unload_cleanup_for_reload(&identity_id.to_buffer())?; + // No stuck-unload repair here: an interrupted unload leaves the marker + // set, so this path has already returned above. Repair belongs to the + // targeted load paths and the startup sweep, which do name an identity. match self.get_identity_by_id(&identity_id)? { Some(existing) => { // Carry DET-only metadata onto the refreshed identity, then @@ -518,11 +532,8 @@ mod tests { use crate::app::TaskResult; use crate::context::identity_load_registry::IdentityLoadPhase; use crate::context::test_support::test_app_context; - use crate::model::qualified_identity::{ - IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, - }; + use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; use crate::utils::egui_mpsc::SenderAsync; - use crate::wallet_backend::IdentityKeyView; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::{Identifier, Identity}; @@ -553,8 +564,12 @@ mod tests { } } + /// No discovery pass restores an identity the user unloaded — not the + /// startup sweep, and not a user-started wallet-wide search, which targets a + /// search depth rather than any particular identity. Only a load aimed at + /// one identity may retire its marker. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn forgotten_identity_is_not_resurrected_by_discovery_and_explicit_load_clears_marker() { + async fn no_discovery_pass_resurrects_a_forgotten_identity() { let temp_dir = tempfile::tempdir().expect("tempdir"); let ctx = test_app_context(temp_dir.path()); let (tx, _rx) = tokio::sync::mpsc::channel::(32); @@ -564,57 +579,52 @@ mod tests { .expect("wire wallet backend offline"); let backend = ctx.wallet_backend().expect("wallet backend"); let wallet = Arc::new(RwLock::new( - Wallet::new_from_seed([0x61; 64], Network::Testnet, None, None).expect("build wallet"), + Wallet::new_from_seed([0x69; 64], Network::Testnet, None, None).expect("build wallet"), )); let wallet_seed_hash = wallet.read().expect("read wallet").seed_hash(); ctx.wallets() .write() .expect("write wallets") .insert(wallet_seed_hash, Arc::clone(&wallet)); - let identity_id = Identifier::from([0x62; 32]); + let identity_id = Identifier::from([0x6A; 32]); let identity = wallet_derived_identity(identity_id, &wallet, 4); ctx.insert_local_qualified_identity(&identity, &None) .expect("insert identity without a stored wallet association"); - ctx.unload_identity(identity_id) - .expect("unload wallet-derived identity"); - assert!( - ctx.is_identity_forgotten(&identity_id) - .expect("read forgotten marker") - ); + ctx.unload_identity(identity_id).expect("unload identity"); let stored = ctx - .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, false) - .expect("simulate discovery persistence"); - assert!(stored.is_none(), "discovery must skip a forgotten identity"); + .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4) + .expect("simulate a discovery pass over the wallet"); + assert!( + stored.is_none(), + "no discovery pass may restore an identity the user unloaded" + ); assert_eq!( ctx.latest_identity_load_phase(&identity_id), Some(IdentityLoadPhase::Loaded), "intentionally skipping a forgotten identity is a successful load no-op" ); + assert!( + ctx.is_identity_forgotten(&identity_id) + .expect("read forgotten marker"), + "a discovery pass must leave the forgotten marker in place" + ); assert!( ctx.get_identity_by_id(&identity_id) .expect("read identity") .is_none(), - "discovery must not resurrect an unloaded identity" + "the unloaded identity must stay unloaded" ); + // A load aimed at this one identity retires its marker — the only way + // back. Discovery then treats the identity like any other again. + ctx.clear_forgotten_identity_after_explicit_load(&identity_id) + .expect("a targeted load retires the marker"); let load_guard = ctx - .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, true) - .expect("simulate explicit wallet load") - .expect("an explicit load must restore the identity"); - ctx.finish_identity_load_after_persist(&identity_id, load_guard); - assert!( - !ctx.is_identity_forgotten(&identity_id) - .expect("read cleared marker") - ); - - ctx.delete_local_qualified_identity(&identity_id) - .expect("remove identity without recording another unload"); - let load_guard = ctx - .persist_discovered_identity(identity.clone(), wallet_seed_hash, 4, false) - .expect("simulate discovery after explicit reload") - .expect("discovery must work normally after explicit reload"); + .persist_discovered_identity(identity, wallet_seed_hash, 4) + .expect("simulate discovery after a targeted load") + .expect("discovery must work normally once the marker is retired"); load_guard.loaded(); assert!( ctx.get_identity_by_id(&identity_id) @@ -622,90 +632,6 @@ mod tests { .is_some() ); - ctx.record_forgotten_identity(&identity_id) - .expect("record forgotten marker"); - let fault_connection = - crate::context::test_support::open_persister_fault_connection(&backend); - fault_connection - .execute_batch( - "CREATE TRIGGER fail_discovery_marker_cleanup - BEFORE DELETE ON meta_global - WHEN OLD.key LIKE 'det:forgotten_identity:%' - BEGIN - SELECT RAISE(FAIL, 'injected discovery marker cleanup failure'); - END;", - ) - .expect("install marker cleanup failure trigger"); - let load_guard = ctx - .persist_discovered_identity(identity, wallet_seed_hash, 4, true) - .expect("persist despite marker cleanup failure") - .expect("durable persistence must remain successful"); - ctx.finish_identity_load_after_persist(&identity_id, load_guard); - assert_eq!( - ctx.latest_identity_load_phase(&identity_id), - Some(IdentityLoadPhase::Loaded) - ); - assert!( - ctx.is_identity_forgotten(&identity_id) - .expect("read retained marker"), - "the injected cleanup fault must leave the marker in place" - ); - - fault_connection - .execute_batch("DROP TRIGGER fail_discovery_marker_cleanup;") - .expect("remove marker cleanup failure trigger"); - backend.shutdown().await; - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn explicit_discovery_clears_repaired_unload_ghost_key() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let ctx = test_app_context(temp_dir.path()); - let (tx, _rx) = tokio::sync::mpsc::channel::(32); - let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); - ctx.ensure_wallet_backend(sender) - .await - .expect("wire wallet backend offline"); - let backend = ctx.wallet_backend().expect("wallet backend"); - let wallet = Arc::new(RwLock::new( - Wallet::new_from_seed([0x66; 64], Network::Testnet, None, None).expect("build wallet"), - )); - let identity_index = 6; - let wallet_seed_hash = wallet.read().expect("read wallet").seed_hash(); - ctx.wallets() - .write() - .expect("write wallets") - .insert(wallet_seed_hash, Arc::clone(&wallet)); - let identity_id = Identifier::from([0x67; 32]); - let replacement = wallet_derived_identity(identity_id, &wallet, identity_index); - let old_key_id = - ctx.install_repaired_unload_ghost_for_test(replacement.identity.clone(), [0x68; 32]); - let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); - assert!( - view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, old_key_id,) - .expect("read old vault key before reload") - .is_some(), - "precondition: the interrupted unload retains its old vault key", - ); - - let load_guard = ctx - .persist_discovered_identity(replacement, wallet_seed_hash, identity_index, true) - .expect("persist explicit discovery") - .expect("explicit discovery must restore the identity"); - ctx.finish_identity_load_after_persist(&identity_id, load_guard); - - assert!( - view.get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, old_key_id,) - .expect("read old vault key after reload") - .is_none(), - "the old vault key must be cleared before explicit discovery writes its blob", - ); - assert!( - !ctx.is_identity_forgotten(&identity_id) - .expect("read marker after reload"), - "a successful explicit discovery must retire the forgotten marker", - ); - backend.shutdown().await; } @@ -731,7 +657,7 @@ mod tests { let identity = wallet_derived_identity(identity_id, &wallet, 5); let load_guard = ctx - .persist_discovered_identity(identity.clone(), wallet_seed_hash, 5, false) + .persist_discovered_identity(identity.clone(), wallet_seed_hash, 5) .expect("persist discovery") .expect("discovery must persist a new identity"); diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 31272806f..171f3b18b 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -271,9 +271,17 @@ impl AppContext { } self.finish_identity_load_after_persist(&identity_id, load_guard); - Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: 1 }) + Ok(BackendTaskSuccessResult::IdentitiesLoaded { + count: 1, + skipped_forgotten: 0, + }) } + /// Search a whole wallet for identities, seeded from a user-supplied index. + /// + /// The search names no identity, so it never restores one the user + /// unloaded; those are reported back as `skipped_forgotten` and stay + /// unloaded until the user loads one by its own index. pub(super) async fn load_user_identities_up_to_index( self: &Arc, wallet_arc_ref: WalletArcRef, @@ -298,7 +306,8 @@ impl AppContext { } Ok(BackendTaskSuccessResult::IdentitiesLoaded { - count: summary.found, + count: summary.stored, + skipped_forgotten: summary.skipped_forgotten, }) } } @@ -426,7 +435,10 @@ mod tests { assert!( matches!( result, - Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: 1 }) + Ok(BackendTaskSuccessResult::IdentitiesLoaded { + count: 1, + skipped_forgotten: 0 + }) ), "the repaired ghost must reload from its wallet: {result:?}", ); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index b1e9af980..f2b56bfdc 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -844,6 +844,10 @@ pub enum BackendTaskSuccessResult { /// Identities were discovered and loaded from a wallet by index search. IdentitiesLoaded { count: u32, + /// Identities the search found but left unloaded because the user had + /// unloaded them. Reported so a search that loads fewer identities than + /// it found is not mistaken for a failure. + skipped_forgotten: u32, }, } diff --git a/src/model/identity_discovery.rs b/src/model/identity_discovery.rs index 4828f8b2b..d7f0e587f 100644 --- a/src/model/identity_discovery.rs +++ b/src/model/identity_discovery.rs @@ -85,6 +85,11 @@ pub struct DiscoverySummary { pub found: u32, /// Identities newly stored or refreshed in the local database. pub stored: u32, + /// Identities left unloaded because the user had unloaded them. Counted + /// separately from the rest of `found - stored` so the user can be told a + /// deliberate unload — not a failure — is why the scan loaded fewer + /// identities than it found. + pub skipped_forgotten: u32, } #[cfg(test)] diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 9ab5f5f0e..135b6e9b3 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -24,6 +24,29 @@ use egui::{Color32, ComboBox, RichText, Ui}; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; +/// Outcome of a wallet identity search, as one message for the user. +/// +/// Shared by the screen and the global banner so both report the same thing. A +/// search never restores an identity the user unloaded, so `skipped_forgotten` +/// gets its own sentence naming the one action that brings such an identity +/// back — without it, the search silently loads fewer identities than it found. +pub(crate) fn wallet_identity_search_message(count: u32, skipped_forgotten: u32) -> String { + let loaded = match count { + 0 => "No new identities were loaded from your wallet.".to_string(), + 1 => "Successfully loaded 1 identity from your wallet.".to_string(), + count => format!("Successfully loaded {count} identities from your wallet."), + }; + match skipped_forgotten { + 0 => loaded, + 1 => format!( + "{loaded} 1 identity you unloaded was left alone. To load it again, search for its own identity index." + ), + skipped => format!( + "{loaded} {skipped} identities you unloaded were left alone. To load one again, search for its own identity index." + ), + } +} + #[derive(Clone, Copy, PartialEq, Eq)] enum LoadIdentityMode { IdentityId, @@ -970,13 +993,13 @@ impl ScreenLike for AddExistingIdentityScreen { self.success_message = Some("Successfully loaded identity.".to_string()); self.add_identity_status = AddIdentityStatus::Complete; } - BackendTaskSuccessResult::IdentitiesLoaded { count } => { + BackendTaskSuccessResult::IdentitiesLoaded { + count, + skipped_forgotten, + } => { self.refresh_banner.take_and_clear(); - self.success_message = Some(if count == 1 { - "Successfully loaded 1 identity from your wallet.".to_string() - } else { - format!("Successfully loaded {count} identities from your wallet.") - }); + self.success_message = + Some(wallet_identity_search_message(count, skipped_forgotten)); self.add_identity_status = AddIdentityStatus::Complete; } BackendTaskSuccessResult::Message(msg) => { @@ -1143,7 +1166,7 @@ impl ScreenLike for AddExistingIdentityScreen { #[cfg(test)] mod load_identity_mode_tests { - use super::LoadIdentityMode; + use super::{LoadIdentityMode, wallet_identity_search_message}; const ALL_MODES: [LoadIdentityMode; 3] = [ LoadIdentityMode::IdentityId, @@ -1171,4 +1194,24 @@ mod load_identity_mode_tests { } } } + + /// A wallet search that leaves an unloaded identity alone must say so, and + /// say how to get it back — otherwise the count silently disagrees with what + /// the user sees in the list. + #[test] + fn search_message_reports_identities_left_unloaded() { + assert_eq!( + wallet_identity_search_message(2, 0), + "Successfully loaded 2 identities from your wallet." + ); + + let one_skipped = wallet_identity_search_message(1, 1); + assert!(one_skipped.starts_with("Successfully loaded 1 identity from your wallet.")); + assert!(one_skipped.contains("1 identity you unloaded was left alone.")); + assert!(one_skipped.contains("search for its own identity index")); + + let all_skipped = wallet_identity_search_message(0, 3); + assert!(all_skipped.starts_with("No new identities were loaded from your wallet.")); + assert!(all_skipped.contains("3 identities you unloaded were left alone.")); + } } diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 405e409df..95184958e 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -23,7 +23,7 @@ use crate::ui::identities::register_dpns_name_screen::{ use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; use crate::ui::identity::settings::{ - UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_message, identity_unload_tip, + UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_dialog, identity_unload_tip, }; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; @@ -44,47 +44,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; -fn identity_removal_message( - primary_cleanup_failed: bool, - associated_cleanup_failed: bool, - associated_removal_failed: bool, -) -> (&'static str, MessageType) { - match ( - primary_cleanup_failed, - associated_cleanup_failed, - associated_removal_failed, - ) { - (false, false, false) => ( - "The identity was removed from this device.", - MessageType::Success, - ), - (true, false, false) => ( - "The identity was removed, but some local data could not be cleaned up. Load and remove it again to retry.", - MessageType::Warning, - ), - (false, true, false) => ( - "The identity and its associated voter identity were removed, but some local voter data could not be cleaned up. Load and remove the identity again to retry the cleanup.", - MessageType::Warning, - ), - (true, true, false) => ( - "The identity and its associated voter identity were removed, but some local data could not be cleaned up. Load and remove both identities again to retry.", - MessageType::Warning, - ), - (false, false, true) => ( - "The identity was removed, but its associated voter identity is still on this device. Wait a moment, then load and remove the identity again to retry.", - MessageType::Warning, - ), - (true, false, true) => ( - "The identity was removed, but some local data could not be cleaned up and its associated voter identity is still on this device. Restart the app, then load and remove the identity again to retry.", - MessageType::Warning, - ), - (_, true, true) => ( - "The identity was removed, but the associated voter identity may still have local data on this device. Restart the app, then load and remove the identity again to retry.", - MessageType::Warning, - ), - } -} - #[derive(Clone, Copy, PartialEq, Eq)] enum IdentitiesSortColumn { Alias, @@ -931,17 +890,13 @@ impl IdentitiesScreen { // the voting-identity variant. match self.app_context.scheduled_vote_count_for_identity(&qualified_identity.identity.id()) { Ok(scheduled_vote_count) => { - let message = identity_removal_confirmation_message( - qualified_identity, - scheduled_vote_count, - ); self.identity_to_remove = Some(qualified_identity.clone()); self.remove_confirmation_dialog = Some( - ConfirmationDialog::new("Confirm Removal", message) - .confirm_text(Some("Yes")) - .cancel_text(Some("No")) - .danger_mode(true), + identity_removal_confirmation_dialog( + qualified_identity, + scheduled_vote_count, + ), ); } Err(error) => { @@ -1186,28 +1141,12 @@ impl ScreenLike for IdentitiesScreen { ); } } - crate::ui::BackendTaskSuccessResult::RemovedIdentities { - identity_ids, - primary_cleanup_failed, - associated_cleanup_failed, - associated_removal_failed, - } => { + crate::ui::BackendTaskSuccessResult::RemovedIdentities { identity_ids, .. } => { + // `AppState` owns the outcome banner, for every dispatcher. let mut identities = self.identities.lock_recover(); for identity_id in identity_ids { identities.shift_remove(&identity_id); } - drop(identities); - let (message, message_type) = identity_removal_message( - primary_cleanup_failed, - associated_cleanup_failed, - associated_removal_failed, - ); - let banner = - MessageBanner::set_global(self.app_context.egui_ctx(), message, message_type); - if primary_cleanup_failed || associated_cleanup_failed || associated_removal_failed - { - banner.disable_auto_dismiss(); - } } _ => {} } @@ -1316,11 +1255,10 @@ impl ScreenLike for IdentitiesScreen { #[cfg(test)] mod tests { - use super::{ - identity_removal_confirmation_message, identity_removal_message, render_identity_name_cell, - }; + use super::render_identity_name_cell; use crate::model::contested_name::PendingUsername; use crate::ui::components::pill::PENDING_USERNAME_PILL_LABEL; + use crate::ui::identity::settings::identity_removal_confirmation_message; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -1366,52 +1304,6 @@ mod tests { ); } - #[test] - fn identity_removal_messages_distinguish_cleanup_outcomes() { - let (message, message_type) = identity_removal_message(false, false, false); - assert_eq!(message, "The identity was removed from this device."); - assert_eq!(message_type, crate::ui::MessageType::Success); - - let (message, message_type) = identity_removal_message(true, false, false); - assert!(message.contains("some local data could not be cleaned up")); - assert!(!message.contains("associated voter identity")); - assert_eq!(message_type, crate::ui::MessageType::Warning); - - let (message, message_type) = identity_removal_message(false, true, false); - assert!(message.contains("associated voter identity were removed")); - assert!(message.contains("could not be cleaned up")); - assert_eq!(message_type, crate::ui::MessageType::Warning); - - let (message, message_type) = identity_removal_message(false, false, true); - assert!(message.contains("associated voter identity is still on this device")); - assert!(!message.contains("local data could not be cleaned up")); - assert!(message.contains("Wait a moment")); - assert!(!message.contains("Restart the app")); - assert_eq!(message_type, crate::ui::MessageType::Warning); - - let (message, message_type) = identity_removal_message(true, false, true); - assert!(message.contains("some local data could not be cleaned up")); - assert!(message.contains("associated voter identity is still on this device")); - assert_eq!(message_type, crate::ui::MessageType::Warning); - - // QA (issue #889 review): the two combos the original test left - // unexercised — both cleanup flags true (primary AND associated - // cleanup left residue, but nothing was left un-removed), and the - // `(_, true, true)` catch-all arm (defensive: `remove_identity` - // never sets `associated_cleanup_failed` and - // `associated_removal_failed` together, but the match is exhaustive - // over all 8 bool combinations regardless). - let (message, message_type) = identity_removal_message(true, true, false); - assert!(message.contains("associated voter identity were removed")); - assert!(message.contains("some local data could not be cleaned up")); - assert!(message.contains("both identities")); - assert_eq!(message_type, crate::ui::MessageType::Warning); - - let (message, message_type) = identity_removal_message(false, true, true); - assert!(message.contains("may still have local data")); - assert_eq!(message_type, crate::ui::MessageType::Warning); - } - /// The Identities list Name cell shows the identity's name and, when a DPNS /// registration is pending, a "Pending" pill beside it. #[test] diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 464174e1b..6f53fca09 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -105,6 +105,16 @@ pub(crate) const UNLOAD_DETAILS_LOAD_FAILED: &str = /// identity — masternodes and evonodes, on every screen that removes them. const REMOVE_VOTING_IDENTITY_DISCLOSURE: &str = "This also removes the node's voting identity from this device."; +// Dialog title and button labels per identity kind. Each names the action it +// performs, because a destructive confirmation answered with "Yes" tells the +// user nothing about what they are agreeing to (docs/ux-design-patterns.md §4). +const UNLOAD_IDENTITY_TITLE: &str = "Unload this identity"; +const UNLOAD_IDENTITY_CONFIRM: &str = "Permanently unload"; +const UNLOAD_IDENTITY_CANCEL: &str = "Keep identity"; +const REMOVE_MASTERNODE: &str = "Remove masternode"; +const KEEP_MASTERNODE: &str = "Keep masternode"; +const REMOVE_EVONODE: &str = "Remove evonode"; +const KEEP_EVONODE: &str = "Keep evonode"; // How the identity is restored after the unload — one complete statement per // identity kind. Nodes are loaded by ProTxHash and are never wallet-derived, so // they never carry the wallet or recovery-information wording; the ProTxHash @@ -776,17 +786,10 @@ impl SettingsTab { match app_context.scheduled_vote_count_for_identity(&target_id) { Ok(scheduled_vote_count) => { self.confirm_unload = Some(PendingIdentityUnload { - dialog: ConfirmationDialog::new( - "Unload this identity", - identity_unload_confirmation_message( - identity, - scheduled_vote_count, - ), - ) - .confirm_text(Some("Permanently unload")) - .cancel_text(Some("Keep identity")) - .danger_mode(true) - .blocks_input(true), + dialog: identity_unload_confirmation_dialog( + identity, + scheduled_vote_count, + ), target_id, }); } @@ -1091,6 +1094,123 @@ pub(crate) fn identity_removal_confirmation_message( } } +/// Outcome of a completed identity removal, as text for the user and the banner +/// severity to show it at. Shared by every dispatcher of the removal task: the +/// three cleanup flags are the only difference between a clean removal and one +/// that left keys on disk, so a screen that ignores them shows a success that +/// may not be one. +/// +/// Each variant states what was removed, what may remain, and the retry the user +/// can perform themselves. +pub(crate) fn identity_removal_message( + primary_cleanup_failed: bool, + associated_cleanup_failed: bool, + associated_removal_failed: bool, +) -> (&'static str, MessageType) { + match ( + primary_cleanup_failed, + associated_cleanup_failed, + associated_removal_failed, + ) { + (false, false, false) => ( + "The identity was removed from this device.", + MessageType::Success, + ), + (true, false, false) => ( + "The identity was removed, but some local data could not be cleaned up. Load and remove it again to retry.", + MessageType::Warning, + ), + (false, true, false) => ( + "The identity and its associated voter identity were removed, but some local voter data could not be cleaned up. Load and remove the identity again to retry the cleanup.", + MessageType::Warning, + ), + (true, true, false) => ( + "The identity and its associated voter identity were removed, but some local data could not be cleaned up. Load and remove both identities again to retry.", + MessageType::Warning, + ), + (false, false, true) => ( + "The identity was removed, but its associated voter identity is still on this device. Wait a moment, then load and remove the identity again to retry.", + MessageType::Warning, + ), + (true, false, true) => ( + "The identity was removed, but some local data could not be cleaned up and its associated voter identity is still on this device. Restart the app, then load and remove the identity again to retry.", + MessageType::Warning, + ), + (_, true, true) => ( + "The identity was removed, but the associated voter identity may still have local data on this device. Restart the app, then load and remove the identity again to retry.", + MessageType::Warning, + ), + } +} + +/// Title and button labels of an unload or removal confirmation. +struct UnloadDialogLabels { + title: &'static str, + confirm: &'static str, + cancel: &'static str, +} + +/// The wording every unload and removal confirmation uses for `identity_type`. +/// Derived from the identity kind rather than supplied by the screen, so no +/// call site can invent labels for an action that deletes private keys. +fn unload_dialog_labels(identity_type: IdentityType) -> UnloadDialogLabels { + match identity_type { + IdentityType::Masternode => UnloadDialogLabels { + title: REMOVE_MASTERNODE, + confirm: REMOVE_MASTERNODE, + cancel: KEEP_MASTERNODE, + }, + IdentityType::Evonode => UnloadDialogLabels { + title: REMOVE_EVONODE, + confirm: REMOVE_EVONODE, + cancel: KEEP_EVONODE, + }, + IdentityType::User => UnloadDialogLabels { + title: UNLOAD_IDENTITY_TITLE, + confirm: UNLOAD_IDENTITY_CONFIRM, + cancel: UNLOAD_IDENTITY_CANCEL, + }, + } +} + +/// Confirmation dialog for unloading `identity` from this device. +/// +/// Danger-styled and input-blocking: the action deletes private keys and cannot +/// be undone, so it must not be answerable by a stray click on the screen +/// behind it. Use [`identity_removal_confirmation_dialog`] where the action also +/// removes the identity's voting identity. +pub(crate) fn identity_unload_confirmation_dialog( + identity: &QualifiedIdentity, + scheduled_vote_count: usize, +) -> ConfirmationDialog { + unload_dialog( + identity, + identity_unload_confirmation_message(identity, scheduled_vote_count), + ) +} + +/// Confirmation dialog for removing `identity` and the voting identity it +/// carries. Same treatment and labels as [`identity_unload_confirmation_dialog`], +/// with the voting-identity consequence added to the disclosure. +pub(crate) fn identity_removal_confirmation_dialog( + identity: &QualifiedIdentity, + scheduled_vote_count: usize, +) -> ConfirmationDialog { + unload_dialog( + identity, + identity_removal_confirmation_message(identity, scheduled_vote_count), + ) +} + +fn unload_dialog(identity: &QualifiedIdentity, message: String) -> ConfirmationDialog { + let labels = unload_dialog_labels(identity.identity_type); + ConfirmationDialog::new(labels.title, message) + .confirm_text(Some(labels.confirm)) + .cancel_text(Some(labels.cancel)) + .danger_mode(true) + .blocks_input(true) +} + fn identity_unload_confirmation_message_for( identity_label: &str, identity_id: Option<&str>, @@ -1586,6 +1706,123 @@ mod tests { ); } + #[test] + fn identity_removal_messages_distinguish_cleanup_outcomes() { + let (message, message_type) = identity_removal_message(false, false, false); + assert_eq!(message, "The identity was removed from this device."); + assert_eq!(message_type, MessageType::Success); + + let (message, message_type) = identity_removal_message(true, false, false); + assert!(message.contains("some local data could not be cleaned up")); + assert!(!message.contains("associated voter identity")); + assert_eq!(message_type, MessageType::Warning); + + let (message, message_type) = identity_removal_message(false, true, false); + assert!(message.contains("associated voter identity were removed")); + assert!(message.contains("could not be cleaned up")); + assert_eq!(message_type, MessageType::Warning); + + let (message, message_type) = identity_removal_message(false, false, true); + assert!(message.contains("associated voter identity is still on this device")); + assert!(!message.contains("local data could not be cleaned up")); + assert!(message.contains("Wait a moment")); + assert!(!message.contains("Restart the app")); + assert_eq!(message_type, MessageType::Warning); + + let (message, message_type) = identity_removal_message(true, false, true); + assert!(message.contains("some local data could not be cleaned up")); + assert!(message.contains("associated voter identity is still on this device")); + assert_eq!(message_type, MessageType::Warning); + + // Both cleanup flags set (primary and associated cleanup left residue, + // but nothing was left un-removed), then the `(_, true, true)` catch-all + // arm: `remove_identity` never sets `associated_cleanup_failed` and + // `associated_removal_failed` together, but the match covers all 8 + // combinations regardless. + let (message, message_type) = identity_removal_message(true, true, false); + assert!(message.contains("associated voter identity were removed")); + assert!(message.contains("some local data could not be cleaned up")); + assert!(message.contains("both identities")); + assert_eq!(message_type, MessageType::Warning); + + let (message, message_type) = identity_removal_message(false, true, true); + assert!(message.contains("may still have local data")); + assert_eq!(message_type, MessageType::Warning); + } + + /// A removal that left residue must never auto-dismiss its banner: the user + /// has a retry to perform and cannot act on a warning they did not see. + #[test] + fn only_a_clean_removal_reports_success() { + for (primary, associated_cleanup, associated_removal) in [ + (true, false, false), + (false, true, false), + (false, false, true), + (true, true, true), + ] { + let (_, message_type) = + identity_removal_message(primary, associated_cleanup, associated_removal); + assert_eq!( + message_type, + MessageType::Warning, + "a removal with residue must not report success", + ); + } + } + + /// Every unload and removal confirmation names the action on its buttons. + /// A generic Yes/No pair is forbidden for a destructive action, and the node + /// verbs are fixed copy the masternode detail view is specified against. + #[test] + fn unload_dialog_labels_name_the_action_for_every_identity_kind() { + for identity_type in [ + IdentityType::User, + IdentityType::Masternode, + IdentityType::Evonode, + ] { + let labels = unload_dialog_labels(identity_type); + for label in [labels.title, labels.confirm, labels.cancel] { + assert!( + !["Yes", "No", "OK", "Confirm", "Cancel"].contains(&label), + "{identity_type:?} must not answer a destructive action with {label:?}" + ); + } + } + + let node = unload_dialog_labels(IdentityType::Masternode); + assert_eq!(node.title, REMOVE_MASTERNODE); + assert_eq!(node.confirm, REMOVE_MASTERNODE); + assert_eq!( + unload_dialog_labels(IdentityType::Evonode).confirm, + REMOVE_EVONODE + ); + } + + /// Both dialog flavours are irreversible, so both register egui's modal + /// layer: a click on the screen behind must never reach the app while the + /// confirmation is open. + #[test] + fn unload_dialogs_block_input_behind_them() { + let identity = qualified_identity_with(19, Some("Blocking identity")); + for mut dialog in [ + identity_unload_confirmation_dialog(&identity, 0), + identity_removal_confirmation_dialog(&identity, 0), + ] { + let ctx = egui::Context::default(); + // Two passes: `set_modal_layer` is consumed at the end of the pass + // that registers it, so it is observable from the next one. + for _ in 0..2 { + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + dialog.show(ui); + }); + } + assert!( + ctx.memory(|memory| memory.top_modal_layer().is_some()), + "an unload confirmation must block input to the screen behind it" + ); + } + } + #[test] fn unload_dialog_warns_about_recovery_information_for_mixed_keys() { let mut identity = qualified_identity_with(12, Some("Mixed identity")); diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index ec1d8d6eb..056480b0f 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -36,7 +36,7 @@ use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identity::identity_picker_card::draw_type_badge; use crate::ui::identity::identity_pill::shorten_id; use crate::ui::identity::settings::{ - UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_message, + UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_dialog, }; use crate::ui::masternodes::card::{ PLATFORM_IDENTITY_STATUS_TOOLTIP, platform_identity_status_label, @@ -975,18 +975,10 @@ impl MasternodeDetailView { .scheduled_vote_count_for_identity(&identity_id) { Ok(scheduled_vote_count) => { - self.remove_dialog = Some( - ConfirmationDialog::new( - "Remove masternode", - identity_removal_confirmation_message( - &self.identity, - scheduled_vote_count, - ), - ) - .danger_mode(true) - // §7 confirm verb (TC-US4-02). - .confirm_text(Some("Remove masternode")), - ); + self.remove_dialog = Some(identity_removal_confirmation_dialog( + &self.identity, + scheduled_vote_count, + )); } Err(error) => { MessageBanner::set_global( diff --git a/tests/kittest/identities_screen.rs b/tests/kittest/identities_screen.rs index ca37cdc1a..bd303a4f2 100644 --- a/tests/kittest/identities_screen.rs +++ b/tests/kittest/identities_screen.rs @@ -1,5 +1,15 @@ -use crate::support::with_isolated_data_dir; +use crate::support::{mount_app, with_isolated_data_dir}; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::{RootScreenType, ScreenLike}; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::collections::BTreeMap; +use std::sync::Arc; /// Test that the identities screen can be rendered #[test] @@ -88,3 +98,70 @@ fn test_frame_batch_processing() { } }); } + +/// Seed one keyless user identity into the live per-network identity DB so the +/// list renders a row with its action buttons. +fn seed_user_identity(app_context: &Arc, byte: u8, alias: &str) { + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), PlatformVersion::latest()) + .expect("basic identity"); + let qualified_identity = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qualified_identity, &None) + .expect("seed user identity"); +} + +/// The list's Remove confirmation is destructive and irreversible, so it must +/// carry the same specific verbs as the Identity Hub's unload confirmation — +/// generic Yes/No labels are forbidden for destructive actions. +#[test] +fn remove_confirmation_uses_specific_verbs() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_user_identity(&app_context, 0xB1, "list-remove-verbs"); + harness + .state_mut() + .active_root_screen_mut() + .refresh_on_arrival(); + harness.run_steps(3); + + harness.get_by_label("Remove").click(); + harness.run_steps(3); + + assert!( + harness.query_by_label("Permanently unload").is_some(), + "the confirm button must name the action it performs" + ); + assert!( + harness.query_by_label("Keep identity").is_some(), + "the cancel button must name the outcome of cancelling" + ); + assert!( + harness.query_by_label("Yes").is_none(), + "a destructive confirmation must not offer a generic Yes" + ); + assert!( + harness.query_by_label("No").is_none(), + "a destructive confirmation must not offer a generic No" + ); + }); +} diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 393285c74..02286212c 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -781,6 +781,45 @@ fn remove_flow_deletes_associated_voter_identity() { }); } +/// The card disappearing is not feedback: a removal that left owner/voter key +/// residue on disk looks identical to a clean one. Every screen that removes an +/// identity must report the outcome, this tab included. +#[test] +fn remove_flow_reports_the_removal_outcome() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node( + &app_context, + 0x99, + "mn-report-outcome", + IdentityType::Masternode, + ); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-report-outcome").click(); + harness.run_steps(3); + harness.get_by_label("Remove masternode").click(); + harness.run_steps(3); + harness + .query_all_by_label("Remove masternode") + .last() + .expect("confirm button present") + .click(); + harness.run_steps(5); + + assert!( + harness + .query_by_label("The identity was removed from this device.") + .is_some(), + "removing a node from this tab must report the cleanup outcome" + ); + }); +} + /// Execution-level: clicking a per-key "Manage keys" button in the masternode /// detail view opens the interactive `KeyInfoScreen` (not the static read-only /// `KeysScreen`). Seeds a node whose voter identity carries one key so From dc27d3cd9fabaf9cdcea98c0654c3cc116ecb0e1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:47:59 +0000 Subject: [PATCH 42/46] fix(identity): close the QA gaps on removal wording, discovery accounting, and logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-9 QA follow-up. Nine items, no behavioural surprises — each closes a gap the first pass left open. The masternode detail view's remove trigger still spelled its own verb, so an evonode was offered a masternode's wording on the button that opens a kind-derived confirmation. `unload_dialog_labels` is now `pub(crate)` and labels the trigger too; its disabled tooltip stops naming one node kind. The wallet search's disclosure named a control a basic-mode user cannot reach: the specific-index search only renders under Show Advanced Options, which basic mode overrides. The copy now names the toggle first, and every case of `wallet_identity_search_message` is one whole template instead of sentences spliced at runtime — a translator sees the complete message and can reorder it. A discovery pass that failed to store what it found reported nothing: the per-identity error was logged and dropped, so a scan whose every write failed rendered as a green "loaded 0". `DiscoverySummary` counts `failed`, the completion log carries `skipped_forgotten` and `failed` alongside `found`/ `stored`, and the result downgrades to a warning that names the retry. The By-Wallet screen keeps its form up in that case, so the retry is one click. `remove_identity` logged only one of its three residue flags, leaving the other two invisible behind a persistent "please retry" banner. Both now warn where they are detected. Also: the ghost-repair comment claimed a startup sweep that does not exist (the only other caller is `clear_network_database`'s wipe), `identity_removal_message` still argued for the screen-local banner this round replaced, and `docs/user-stories.md` overstated both the automatic sweep's reporting and the ways back from an unload. Tests: an evonode remove-trigger kittest and a residue-banner kittest driving a failed removal through the app's own task channel — both confirmed failing against mutated fixes, so they bite. Plus message-template unit tests covering severity and the single-template rule. Co-Authored-By: Claude Opus 5 --- docs/user-stories.md | 7 +- src/app.rs | 6 +- .../identity/discover_identities.rs | 18 ++- .../identity/load_identity_from_wallet.rs | 5 +- src/backend_task/identity/remove_identity.rs | 17 ++- src/backend_task/mod.rs | 4 + src/model/identity_discovery.rs | 4 + .../add_existing_identity_screen.rs | 136 +++++++++++++----- src/ui/identity/settings.rs | 27 ++-- src/ui/masternodes/detail_screen.rs | 12 +- tests/kittest/masternode_tab.rs | 97 +++++++++++++ 11 files changed, 269 insertions(+), 64 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index 593eee5a1..7e8afb831 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -652,7 +652,7 @@ As a user, I want my wallet's identities to be found and loaded automatically on - The search uses a rolling five-index lookahead, going deeper each time an identity is found, so identities at non-contiguous indices are discovered. - Already-loaded identities are refreshed (new keys, new DPNS names) while any alias the user assigned is preserved. - Locked, password-protected wallets are skipped without prompting; they are searched after the user unlocks them. -- No search of a whole wallet — automatic or user-started — brings back an identity the user unloaded. Those are left alone, and the result reports how many, so a smaller count is not mistaken for a failure. +- No search of a whole wallet — automatic or user-started — brings back an identity the user unloaded. The automatic sweep leaves them alone silently; the user-started "Load Identity → From my wallet" search reports how many it left alone, so a smaller count is not mistaken for a failure. That search also reports identities it found but could not save on this device. ### IDN-016: Identities and their keys preserved across an app upgrade [Implemented] **Persona:** Alex, Priya @@ -682,8 +682,9 @@ As a user, I want to unload one identity from this device so that I can recover automatic discovery does not bring the identity back. All three confirmations use the same danger-styled, input-blocking dialog with buttons that name the action instead of a generic Yes/No pair. -- The recorded unload also survives a user-started search of the whole wallet; - only loading that identity by its own index (or by its ID) brings it back. +- The recorded unload also survives a user-started search of the whole wallet. + Only a load aimed at that one identity brings it back: its own wallet index, + its identity ID, or its username. - Removal reports its outcome, whichever screen it was started from, and says what to retry when local data or the node's voting identity could not be cleaned up. diff --git a/src/app.rs b/src/app.rs index d56060007..9e479f227 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2584,9 +2584,11 @@ impl App for AppState { BackendTaskSuccessResult::IdentitiesLoaded { count, skipped_forgotten, + failed, } => { - let msg = wallet_identity_search_message(count, skipped_forgotten); - MessageBanner::set_global(ctx, &msg, MessageType::Success); + let (msg, message_type) = + wallet_identity_search_message(count, skipped_forgotten, failed); + MessageBanner::set_global(ctx, &msg, message_type); self.visible_screen_mut() .display_backend_task_result(&context, unboxed_message); } diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs index c468e4916..f7f642ae8 100644 --- a/src/backend_task/identity/discover_identities.rs +++ b/src/backend_task/identity/discover_identities.rs @@ -218,11 +218,14 @@ impl AppContext { summary.skipped_forgotten = summary.skipped_forgotten.saturating_add(1) } Ok(DiscoveredIdentityOutcome::Skipped) => {} - Err(e) => tracing::warn!( - identity_id = %identity_id, - error = %e, - "Failed to store discovered identity" - ), + Err(e) => { + summary.failed = summary.failed.saturating_add(1); + tracing::warn!( + identity_id = %identity_id, + error = %e, + "Failed to store discovered identity" + ); + } } } @@ -233,6 +236,8 @@ impl AppContext { seed = %hex::encode(seed_hash), found = summary.found, stored = summary.stored, + skipped_forgotten = summary.skipped_forgotten, + failed = summary.failed, "Gap-limited identity discovery complete" ); @@ -347,7 +352,8 @@ impl AppContext { // No stuck-unload repair here: an interrupted unload leaves the marker // set, so this path has already returned above. Repair belongs to the - // targeted load paths and the startup sweep, which do name an identity. + // targeted load paths, which name an identity, and to + // `clear_network_database`, which sweeps every marker on a full wipe. match self.get_identity_by_id(&identity_id)? { Some(existing) => { // Carry DET-only metadata onto the refreshed identity, then diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 171f3b18b..f884ec0f5 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -274,6 +274,7 @@ impl AppContext { Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: 1, skipped_forgotten: 0, + failed: 0, }) } @@ -308,6 +309,7 @@ impl AppContext { Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: summary.stored, skipped_forgotten: summary.skipped_forgotten, + failed: summary.failed, }) } } @@ -437,7 +439,8 @@ mod tests { result, Ok(BackendTaskSuccessResult::IdentitiesLoaded { count: 1, - skipped_forgotten: 0 + skipped_forgotten: 0, + failed: 0 }) ), "the repaired ghost must reload from its wallet: {result:?}", diff --git a/src/backend_task/identity/remove_identity.rs b/src/backend_task/identity/remove_identity.rs index 7c8da5fd2..91b1aa788 100644 --- a/src/backend_task/identity/remove_identity.rs +++ b/src/backend_task/identity/remove_identity.rs @@ -23,9 +23,19 @@ impl AppContext { // failure (`identity_was_removed()`) from a genuine removal failure so // in-memory state still gets reconciled in the former case, matching // `unload_identity()`'s contract instead of aborting via a bare `?`. + // Each residue below is logged where it is detected: the user is told to + // retry, and that report is only diagnosable against a log line naming + // the identity and the underlying error. let cleanup_error = match self.unload_local_qualified_identity(&identity_id) { Ok(()) => None, - Err(error) if error.identity_was_removed() => Some(error), + Err(error) if error.identity_was_removed() => { + tracing::warn!( + ?error, + identity = %identity_id, + "Removed identity but left some of its local data behind" + ); + Some(error) + } Err(error) => return Err(error), }; self.reconcile_unloaded_identity_memory(&identity_id); @@ -44,6 +54,11 @@ impl AppContext { self.reconcile_unloaded_identity_memory(&voter_id); removed_identity_ids.push(voter_id); associated_cleanup_failed = true; + tracing::warn!( + ?error, + voter_identity_id = %voter_id, + "Removed the associated voter identity but left some of its local data behind" + ); } Err(error) => { associated_removal_failed = true; diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index f2b56bfdc..83e866d37 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -848,6 +848,10 @@ pub enum BackendTaskSuccessResult { /// unloaded them. Reported so a search that loads fewer identities than /// it found is not mistaken for a failure. skipped_forgotten: u32, + /// Identities the search found but could not store on this device. + /// Reported so a search whose writes all failed is not mistaken for one + /// that found nothing new. + failed: u32, }, } diff --git a/src/model/identity_discovery.rs b/src/model/identity_discovery.rs index d7f0e587f..b75931a0c 100644 --- a/src/model/identity_discovery.rs +++ b/src/model/identity_discovery.rs @@ -90,6 +90,10 @@ pub struct DiscoverySummary { /// deliberate unload — not a failure — is why the scan loaded fewer /// identities than it found. pub skipped_forgotten: u32, + /// Identities found on the network that could not be stored on this device. + /// Without this count a scan whose every write failed is indistinguishable + /// from one that simply found nothing new. + pub failed: u32, } #[cfg(test)] diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 135b6e9b3..2ef779108 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -24,25 +24,51 @@ use egui::{Color32, ComboBox, RichText, Ui}; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; -/// Outcome of a wallet identity search, as one message for the user. +/// Outcome of a wallet identity search, as one message for the user and the +/// banner severity to show it at. /// -/// Shared by the screen and the global banner so both report the same thing. A -/// search never restores an identity the user unloaded, so `skipped_forgotten` -/// gets its own sentence naming the one action that brings such an identity -/// back — without it, the search silently loads fewer identities than it found. -pub(crate) fn wallet_identity_search_message(count: u32, skipped_forgotten: u32) -> String { - let loaded = match count { - 0 => "No new identities were loaded from your wallet.".to_string(), - 1 => "Successfully loaded 1 identity from your wallet.".to_string(), - count => format!("Successfully loaded {count} identities from your wallet."), - }; - match skipped_forgotten { - 0 => loaded, - 1 => format!( - "{loaded} 1 identity you unloaded was left alone. To load it again, search for its own identity index." +/// Shared by the screen and the global banner so both report the same thing. +/// Each case is one whole template rather than sentences spliced at runtime, so +/// a translator sees the complete message and can reorder it freely. Counts stay +/// plural-neutral ("identity(ies)"), matching the scheduled-vote wording in the +/// unload confirmation. +/// +/// A search never restores an identity the user unloaded, and it can find an +/// identity it then fails to store; both are called out, because either one +/// silently loads fewer identities than the search found. +pub(crate) fn wallet_identity_search_message( + count: u32, + skipped_forgotten: u32, + failed: u32, +) -> (String, MessageType) { + match (skipped_forgotten > 0, failed > 0) { + (false, false) => ( + format!("Loaded {count} identity(ies) from your wallet."), + MessageType::Success, + ), + (true, false) => ( + format!( + "Loaded {count} identity(ies) from your wallet. {skipped_forgotten} identity(ies) \ + you unloaded were left alone. To load one again, turn on Show Advanced Options \ + and search for that identity's own index." + ), + MessageType::Success, + ), + (false, true) => ( + format!( + "Loaded {count} identity(ies) from your wallet. {failed} identity(ies) could not \ + be saved on this device. Search again in a moment to load them." + ), + MessageType::Warning, ), - skipped => format!( - "{loaded} {skipped} identities you unloaded were left alone. To load one again, search for its own identity index." + (true, true) => ( + format!( + "Loaded {count} identity(ies) from your wallet. {failed} identity(ies) could not \ + be saved on this device. Search again in a moment to load them. \ + {skipped_forgotten} identity(ies) you unloaded were left alone. To load one \ + again, turn on Show Advanced Options and search for that identity's own index." + ), + MessageType::Warning, ), } } @@ -996,11 +1022,20 @@ impl ScreenLike for AddExistingIdentityScreen { BackendTaskSuccessResult::IdentitiesLoaded { count, skipped_forgotten, + failed, } => { self.refresh_banner.take_and_clear(); - self.success_message = - Some(wallet_identity_search_message(count, skipped_forgotten)); - self.add_identity_status = AddIdentityStatus::Complete; + let (message, message_type) = + wallet_identity_search_message(count, skipped_forgotten, failed); + match message_type { + MessageType::Success => { + self.success_message = Some(message); + self.add_identity_status = AddIdentityStatus::Complete; + } + // A search that could not store what it found keeps the form + // up, so the retry the banner asks for is one click away. + _ => self.add_identity_status = AddIdentityStatus::NotStarted, + } } BackendTaskSuccessResult::Message(msg) => { // Check if this is a final success message or a progress update @@ -1166,7 +1201,7 @@ impl ScreenLike for AddExistingIdentityScreen { #[cfg(test)] mod load_identity_mode_tests { - use super::{LoadIdentityMode, wallet_identity_search_message}; + use super::{LoadIdentityMode, MessageType, wallet_identity_search_message}; const ALL_MODES: [LoadIdentityMode; 3] = [ LoadIdentityMode::IdentityId, @@ -1196,22 +1231,59 @@ mod load_identity_mode_tests { } /// A wallet search that leaves an unloaded identity alone must say so, and - /// say how to get it back — otherwise the count silently disagrees with what - /// the user sees in the list. + /// name an action that gets it back — otherwise the count silently disagrees + /// with what the user sees in the list. #[test] fn search_message_reports_identities_left_unloaded() { + let (clean, message_type) = wallet_identity_search_message(2, 0, 0); + assert_eq!(clean, "Loaded 2 identity(ies) from your wallet."); + assert_eq!(message_type, MessageType::Success); + + let (skipped, message_type) = wallet_identity_search_message(1, 1, 0); + assert!(skipped.contains("1 identity(ies) you unloaded were left alone.")); + assert!(skipped.contains("turn on Show Advanced Options")); assert_eq!( - wallet_identity_search_message(2, 0), - "Successfully loaded 2 identities from your wallet." + message_type, + MessageType::Success, + "a deliberate unload the search honoured is not a failure", ); + } - let one_skipped = wallet_identity_search_message(1, 1); - assert!(one_skipped.starts_with("Successfully loaded 1 identity from your wallet.")); - assert!(one_skipped.contains("1 identity you unloaded was left alone.")); - assert!(one_skipped.contains("search for its own identity index")); + /// A search that found identities it could not store must not render as a + /// plain success — that is indistinguishable from finding nothing new. + #[test] + fn search_message_warns_when_identities_could_not_be_saved() { + let (failed_only, message_type) = wallet_identity_search_message(0, 0, 2); + assert!(failed_only.contains("2 identity(ies) could not be saved on this device.")); + assert!(failed_only.contains("Search again in a moment")); + assert_eq!(message_type, MessageType::Warning); + + let (both, message_type) = wallet_identity_search_message(1, 3, 2); + assert!(both.contains("2 identity(ies) could not be saved on this device.")); + assert!(both.contains("3 identity(ies) you unloaded were left alone.")); + assert_eq!(message_type, MessageType::Warning); + } - let all_skipped = wallet_identity_search_message(0, 3); - assert!(all_skipped.starts_with("No new identities were loaded from your wallet.")); - assert!(all_skipped.contains("3 identities you unloaded were left alone.")); + /// Every case is one whole template: the message must never be assembled + /// from sentences chosen at runtime, which no translator can reorder. + #[test] + fn search_message_names_an_action_and_stays_one_template() { + for (count, skipped, failed) in [(2, 0, 0), (2, 1, 0), (2, 0, 1), (2, 1, 1)] { + let (message, _) = wallet_identity_search_message(count, skipped, failed); + assert!( + message.starts_with("Loaded 2 identity(ies) from your wallet."), + "every case opens with the same complete sentence: {message}" + ); + assert!( + message.ends_with('.'), + "every case is a set of complete sentences: {message}" + ); + if skipped > 0 || failed > 0 { + assert!( + message.contains("Show Advanced Options") || message.contains("Search again"), + "a shortfall must name what the user can do about it: {message}" + ); + } + } } } diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 6f53fca09..675f87edb 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -1095,13 +1095,12 @@ pub(crate) fn identity_removal_confirmation_message( } /// Outcome of a completed identity removal, as text for the user and the banner -/// severity to show it at. Shared by every dispatcher of the removal task: the -/// three cleanup flags are the only difference between a clean removal and one -/// that left keys on disk, so a screen that ignores them shows a success that -/// may not be one. +/// severity to show it at. `AppState` calls this for whichever screen dispatched +/// the removal, so the wording is the same wherever the removal was started. /// -/// Each variant states what was removed, what may remain, and the retry the user -/// can perform themselves. +/// Each arm states what was removed, what may remain, and the retry the user can +/// perform themselves. The three cleanup flags are the only difference between a +/// clean removal and one that left keys on disk. pub(crate) fn identity_removal_message( primary_cleanup_failed: bool, associated_cleanup_failed: bool, @@ -1143,17 +1142,19 @@ pub(crate) fn identity_removal_message( } } -/// Title and button labels of an unload or removal confirmation. -struct UnloadDialogLabels { - title: &'static str, - confirm: &'static str, - cancel: &'static str, +/// Title and button labels of an unload or removal confirmation. `confirm` also +/// labels the control that opens the confirmation, so the button a user clicks +/// and the button they confirm with cannot name different actions. +pub(crate) struct UnloadDialogLabels { + pub(crate) title: &'static str, + pub(crate) confirm: &'static str, + pub(crate) cancel: &'static str, } -/// The wording every unload and removal confirmation uses for `identity_type`. +/// The wording every unload and removal control uses for `identity_type`. /// Derived from the identity kind rather than supplied by the screen, so no /// call site can invent labels for an action that deletes private keys. -fn unload_dialog_labels(identity_type: IdentityType) -> UnloadDialogLabels { +pub(crate) fn unload_dialog_labels(identity_type: IdentityType) -> UnloadDialogLabels { match identity_type { IdentityType::Masternode => UnloadDialogLabels { title: REMOVE_MASTERNODE, diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 056480b0f..fe21d2ca6 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -36,7 +36,7 @@ use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identity::identity_picker_card::draw_type_badge; use crate::ui::identity::identity_pill::shorten_id; use crate::ui::identity::settings::{ - UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_dialog, + UNLOAD_DETAILS_LOAD_FAILED, identity_removal_confirmation_dialog, unload_dialog_labels, }; use crate::ui::masternodes::card::{ PLATFORM_IDENTITY_STATUS_TOOLTIP, platform_identity_status_label, @@ -957,13 +957,13 @@ impl MasternodeDetailView { /// Dispatch the shared removal task after confirmation. fn render_remove_section(&mut self, ui: &mut Ui, _dark_mode: bool) -> Option { let migration_in_progress = self.app_context.migration_status().state().is_in_progress(); + // The trigger takes the same kind-derived verb as the confirmation it + // opens, so an evonode is never offered a masternode's wording. + let remove_verb = unload_dialog_labels(self.identity.identity_type).confirm; if ui - .add_enabled( - !migration_in_progress, - egui::Button::new("Remove masternode"), - ) + .add_enabled(!migration_in_progress, egui::Button::new(remove_verb)) .on_disabled_hover_text( - "Wait for the storage update to finish before removing this masternode.", + "Wait for the storage update to finish before removing this node.", ) .clicked() { diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 02286212c..8351dbbe8 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -820,6 +820,103 @@ fn remove_flow_reports_the_removal_outcome() { }); } +/// An evonode is not a masternode: the control that opens the removal +/// confirmation must carry the same kind-derived verb as the confirmation +/// itself, or the two disagree about what the click does. +#[test] +fn remove_trigger_names_the_node_kind() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + seed_node(&app_context, 0x9A, "evo-remove-verb", IdentityType::Evonode); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open evo-remove-verb").click(); + harness.run_steps(3); + + assert!( + harness.query_by_label("Remove evonode").is_some(), + "an evonode's remove button must name the evonode" + ); + assert!( + harness.query_by_label("Remove masternode").is_none(), + "an evonode must never be offered a masternode's wording" + ); + + // The confirmation it opens agrees with the trigger. + harness.get_by_label("Remove evonode").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("Keep evonode").is_some(), + "the confirmation must be the kind-derived one" + ); + }); +} + +/// The failure path of the removal banner: cleanup residue must reach the user +/// as a warning that stays put, not a success that fades. Drives the result +/// through the app's own task channel, which is the wiring `AppState` owns — +/// production of the flags themselves is covered by the fault-injection tests in +/// `backend_task::identity::remove_identity`. +#[test] +fn removal_with_cleanup_residue_warns_and_stays_on_screen() { + use dash_evo_tool::app::TaskResult; + use dash_evo_tool::backend_task::{BackendTaskContext, BackendTaskSuccessResult}; + + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + let node_id = Identifier::from([0x9B; 32]); + seed_node(&app_context, 0x9B, "mn-residue", IdentityType::Masternode); + activate_masternodes_tab(&mut harness, &app_context); + + harness + .state() + .task_result_sender + .try_send(TaskResult::Success { + context: BackendTaskContext::Unknown, + result: Box::new(BackendTaskSuccessResult::RemovedIdentities { + identity_ids: vec![node_id], + primary_cleanup_failed: true, + associated_cleanup_failed: false, + associated_removal_failed: false, + }), + }) + .expect("queue the removal result"); + harness.run_steps(5); + + assert!( + harness + .query_by_label( + "The identity was removed, but some local data could not be cleaned up. Load \ + and remove it again to retry." + ) + .is_some(), + "cleanup residue must be reported, not swallowed" + ); + assert!( + harness.query_by_label("\u{26A0}").is_some(), + "residue is a warning, not a success" + ); + // An auto-dismissing banner renders a countdown beside its dismiss + // control; a warning's window is 9s, so only these two can appear in the + // frames this test runs. Their absence is the durable signal that + // `disable_auto_dismiss` was applied. + for countdown in ["(9s)", "(10s)"] { + assert!( + harness.query_by_label(countdown).is_none(), + "a banner the user must act on cannot fade away on its own" + ); + } + }); +} + /// Execution-level: clicking a per-key "Manage keys" button in the masternode /// detail view opens the interactive `KeyInfoScreen` (not the static read-only /// `KeysScreen`). Seeds a node whose voter identity carries one key so From c7d037077d32057c04569109061ecc127189d46d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:54:33 +0000 Subject: [PATCH 43/46] fix(identity): the unload disclosure holds against every search, not just automatic ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation shown while authorizing irreversible key deletion still said "automatic discovery does not bring it back". That qualifier was accurate only while the background pass alone honoured the forgotten marker; every discovery pass now does. Left standing, the word invited the reader to believe a manual search would undo the unload — the one inference this dialog cannot afford. It now says no search brings it back, and a test rejects any future qualifier. The way back named "Show Advanced Options", which is a real control but the long way round: the identity-ID and username routes have permanent mode buttons and, in default mode, derive their keys from the loaded wallet, so neither asks for a private key this device just deleted. The search message names those. Co-Authored-By: Claude Opus 5 --- docs/user-stories.md | 4 +-- .../add_existing_identity_screen.rs | 19 ++++++++----- src/ui/identity/settings.rs | 27 ++++++++++++------- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index 7e8afb831..72963fd97 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -678,8 +678,8 @@ As a user, I want to unload one identity from this device so that I can recover device are permanently deleted and require separate recovery information. If scheduled votes are queued, the confirmation states how many will be cancelled. The confirmation also names the synced data that only a full - database clear removes, and discloses that the app records the unload so - automatic discovery does not bring the identity back. All three + database clear removes, and discloses that the app records the unload so no + search brings the identity back. All three confirmations use the same danger-styled, input-blocking dialog with buttons that name the action instead of a generic Yes/No pair. - The recorded unload also survives a user-started search of the whole wallet. diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 2ef779108..87b3dd705 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -35,7 +35,10 @@ use std::sync::{Arc, RwLock}; /// /// A search never restores an identity the user unloaded, and it can find an /// identity it then fails to store; both are called out, because either one -/// silently loads fewer identities than the search found. +/// silently loads fewer identities than the search found. The way back names +/// the identity-ID and username routes: their mode buttons are always on +/// screen, and in default mode both derive the keys from the loaded wallet, so +/// neither asks the user for a key this device no longer holds. pub(crate) fn wallet_identity_search_message( count: u32, skipped_forgotten: u32, @@ -49,8 +52,8 @@ pub(crate) fn wallet_identity_search_message( (true, false) => ( format!( "Loaded {count} identity(ies) from your wallet. {skipped_forgotten} identity(ies) \ - you unloaded were left alone. To load one again, turn on Show Advanced Options \ - and search for that identity's own index." + you unloaded were left alone. To load one again, use its identity ID or its \ + username." ), MessageType::Success, ), @@ -66,7 +69,7 @@ pub(crate) fn wallet_identity_search_message( "Loaded {count} identity(ies) from your wallet. {failed} identity(ies) could not \ be saved on this device. Search again in a moment to load them. \ {skipped_forgotten} identity(ies) you unloaded were left alone. To load one \ - again, turn on Show Advanced Options and search for that identity's own index." + again, use its identity ID or its username." ), MessageType::Warning, ), @@ -1241,7 +1244,10 @@ mod load_identity_mode_tests { let (skipped, message_type) = wallet_identity_search_message(1, 1, 0); assert!(skipped.contains("1 identity(ies) you unloaded were left alone.")); - assert!(skipped.contains("turn on Show Advanced Options")); + assert!( + skipped.contains("use its identity ID or its username"), + "the way back must be a route the default mode offers: {skipped}" + ); assert_eq!( message_type, MessageType::Success, @@ -1280,7 +1286,8 @@ mod load_identity_mode_tests { ); if skipped > 0 || failed > 0 { assert!( - message.contains("Show Advanced Options") || message.contains("Search again"), + message.contains("use its identity ID or its username") + || message.contains("Search again"), "a shortfall must name what the user can do about it: {message}" ); } diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 675f87edb..ccfd264b8 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -1231,8 +1231,8 @@ fn identity_unload_confirmation_message_for( deleting its private keys and its entry in this app.{identity_identification} Some \ synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ - Settings. This app remembers that you unloaded this identity, so automatic discovery \ - does not bring it back. {restoration} This also cancels {scheduled_vote_count} \ + Settings. This app remembers that you unloaded this identity, so no search brings it \ + back. {restoration} This also cancels {scheduled_vote_count} \ scheduled vote(s)." ), false => format!( @@ -1240,8 +1240,8 @@ fn identity_unload_confirmation_message_for( deleting its private keys and its entry in this app.{identity_identification} Some \ synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ - Settings. This app remembers that you unloaded this identity, so automatic discovery \ - does not bring it back. {restoration}" + Settings. This app remembers that you unloaded this identity, so no search brings it \ + back. {restoration}" ), } } @@ -1492,8 +1492,8 @@ mod tests { "Identity \"Wallet identity\" will be permanently unloaded from this device, \ deleting its private keys and its entry in this app. Some synced network data, such \ as contacts and payment history, is removed only by the \"Clear Database\" action in \ - Settings. This app remembers that you unloaded this identity, so automatic discovery \ - does not bring it back. It remains on Dash Platform, and its wallet-derived private \ + Settings. This app remembers that you unloaded this identity, so no search brings it \ + back. It remains on Dash Platform, and its wallet-derived private \ keys can be restored when you load it again." ); assert_eq!(identity_unload_tip_for(false), TIP_UNLOAD_WALLET_DERIVED); @@ -1531,8 +1531,8 @@ mod tests { ); assert!( message.contains( - "This app remembers that you unloaded this identity, so automatic \ - discovery does not bring it back." + "This app remembers that you unloaded this identity, so no search \ + brings it back." ), "the dialog must disclose the durable record of the unload: {message}" ); @@ -1540,6 +1540,15 @@ mod tests { !message.contains("deleting its local data"), "the dialog must not overstate the removal as all local data: {message}" ); + // No discovery pass restores a forgotten identity, so a + // disclosure that qualifies the promise — "automatic discovery", + // "background sync" — invites the user to expect a manual search + // to undo what they are about to authorize. + assert!( + !message.contains("automatic"), + "the record of the unload holds against every search, not just automatic \ + ones: {message}" + ); } } } @@ -1855,7 +1864,7 @@ mod tests { deleting its private keys and its entry in this app. Its full identifier is \ {identity_id}. Some synced network data, such as contacts and payment history, \ is removed only by the \"Clear Database\" action in Settings. This app remembers \ - that you unloaded this identity, so automatic discovery does not bring it back. \ + that you unloaded this identity, so no search brings it back. \ It remains on Dash Platform, but you will need its recovery information to load \ it again." ) From 378dcea998fd9861e079af4d11e505b0629a2adb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:02:01 +0000 Subject: [PATCH 44/46] docs(changelog): document round-9 unload/removal consistency fixes Covers PR #925's CI-review round: unified confirmation dialogs, masternode-tab removal feedback, discovery-marker resurrection closure, and the corrected unload-confirmation wording. --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index effb909b8..fd1c6718d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). wallet discovery — so a leftover key from an earlier interrupted unload is always cleared before a fresh load replaces it, not just on one path. +- **Identity unload/removal confirmations, feedback, and discovery guarantees, made consistent**: + the three places you can unload or remove an identity (Identity Hub → + Settings, the Identities screen, and a masternode's detail view) now show + the same specific-verb, input-blocking confirmation everywhere, instead of + one of them using a generic "Yes"/"No". Removing a masternode or evonode + from the Masternodes tab now reports the same success/warning outcome as + removing it anywhere else, instead of showing no feedback at all when + cleanup left residue behind. A wallet-wide identity search can no longer + bring back an identity you unloaded under any circumstance — previously + only the automatic background scan respected that — and a user-started + search now reports how many identities it left unloaded and how many it + failed to save, instead of looking identical to finding nothing new. The + unload confirmation's own wording was corrected to match: it no longer + implies a manual search can undo the unload, and it now names an actual, + reachable way back (the identity's own ID or username) instead of one + hidden behind an advanced-options toggle. + - **"Clear Database" no longer reports a clean wipe it did not finish**: the wipe now keeps every identity reserved until the last step is done, so an identity being loaded in the background cannot be written back to disk after From 2077f3b475087155e99c604c2693064c4f8a4cfa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:33:30 +0000 Subject: [PATCH 45/46] fix(model): round instead of floor the contest-decision ETA bucket approximate_time_until() floored seconds-to-hours/days, so a target computed from one clock read and checked against a later, independent read (e.g. pill::pending_username_tooltip) could lose a whole bucket to a few elapsed milliseconds -- reported as "about 2 hours" for a 3-hour target. Round to nearest instead; existing exact-multiple test cases are unaffected. Pre-existing failure on this branch, inherited from v1.0-dev, unrelated to issue #889 -- fixed here per explicit request rather than filed separately. --- src/model/contested_name.rs | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index 893dcde65..fd049a864 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -180,7 +180,10 @@ pub fn approximate_time_until(decided_at_ms: TimestampMillis, now_ms: u64) -> Op "Dash masternodes vote on who receives this username. A decision is expected in about 1 hour." .to_string() } else if secs < DAY { - let hours = secs / HOUR; + // Round rather than floor: `secs` is measured from an independent + // clock read than the caller's target, so a few elapsed + // milliseconds must not drop the displayed hour count by one. + let hours = (secs + HOUR / 2) / HOUR; format!( "Dash masternodes vote on who receives this username. A decision is expected in about {hours} hours." ) @@ -188,7 +191,7 @@ pub fn approximate_time_until(decided_at_ms: TimestampMillis, now_ms: u64) -> Op "Dash masternodes vote on who receives this username. A decision is expected in about 1 day." .to_string() } else { - let days = secs / DAY; + let days = (secs + DAY / 2) / DAY; format!( "Dash masternodes vote on who receives this username. A decision is expected in about {days} days." ) @@ -458,6 +461,28 @@ mod tests { ); } + #[test] + fn approximate_time_until_rounds_instead_of_flooring_near_an_hour_or_day_boundary() { + // Callers compute the target from one clock read and pass `now_ms` from + // a second, later read (see `pill::pending_username_tooltip`) — a few + // elapsed milliseconds must not drop the displayed count by a whole + // bucket. + let now = 1_000_000_000_000u64; + let ms = |secs: u64| now + secs * 1_000; + assert_eq!( + approximate_time_until(ms(3 * 3_600 - 1), now).as_deref(), + Some( + "Dash masternodes vote on who receives this username. A decision is expected in about 3 hours." + ) + ); + assert_eq!( + approximate_time_until(ms(3 * 86_400 - 1), now).as_deref(), + Some( + "Dash masternodes vote on who receives this username. A decision is expected in about 3 days." + ) + ); + } + #[test] fn approximate_time_until_is_none_when_deadline_passed_or_now() { let now = 1_000_000_000_000u64; From 06dfbc32e1e45c787bd389dc5d044b40783f5536 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:34:30 +0000 Subject: [PATCH 46/46] docs(changelog): document the ETA-tooltip rounding fix --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f23aafb..a22bf2ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). reachable way back (the identity's own ID or username) instead of one hidden behind an advanced-options toggle. +- **Contest-decision ETA tooltip no longer under-reports the wait**: the + pending-username hover tooltip could round a decision estimate down by a + whole hour or day (e.g. showing "about 2 hours" for a decision actually + about 3 hours away), depending on exactly when it happened to be read. It + now rounds to the nearest hour/day instead of always rounding down. + - **"Clear Database" no longer reports a clean wipe it did not finish**: the wipe now keeps every identity reserved until the last step is done, so an identity being loaded in the background cannot be written back to disk after