From 05d47f4d7b1c61ec7417a4a471eaa2d8a0938848 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:01:25 +0000 Subject: [PATCH 1/9] fix(context): install secret prompt before wiring wallet backend on network switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BackendTask::SwitchNetwork built the new AppContext and immediately wired its wallet backend (ensure_wallet_backend_and_start_spv), which snapshots the context's secret prompt into the SecretAccess chokepoint at construction time (no interior mutability). The real egui prompt host was only installed after the task result returned, by which point the snapshot already happened — so any passphrase-protected operation on a network reached via an in-session switch (not the persisted boot network) permanently fell back to NullSecretPrompt, producing the headless-only "This wallet is protected by a passphrase, which can only be entered in the app window" error inside the actual GUI. The MCP network-switch tool had the identical ordering gap. Propagate the outgoing context's secret prompt onto the new context immediately after creation, before any wallet-backend wiring, in both call sites. Boot-time context creation was already correct and is unaffected. Co-Authored-By: Codex Sol Co-Authored-By: Claude --- src/backend_task/mod.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/mcp/tools/network.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index e096c9b73..384a136df 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -1014,6 +1014,7 @@ impl AppContext { ) }) .ok_or(TaskError::NetworkContextCreationFailed { network })?; + new_ctx.install_secret_prompt(self.secret_prompt()); // Wire the freshly-built context's wallet backend and then start // chain sync. The old code called `start_spv()` on an unwired @@ -1277,6 +1278,43 @@ mod tests { backend.shutdown().await; } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn switch_network_propagates_secret_prompt_before_wallet_backend_wiring() { + use crate::context::test_support::test_app_context; + use crate::wallet_backend::SecretPrompt; + use crate::wallet_backend::secret_prompt::test_support::TestPrompt; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(tmp.path()); + let prompt: Arc = Arc::new(TestPrompt::never()); + ctx.install_secret_prompt(Arc::clone(&prompt)); + + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + let result = ctx + .run_backend_task( + BackendTask::SwitchNetwork { + network: Network::Mainnet, + start_spv: false, + }, + sender, + ) + .await + .expect("switch network"); + + let BackendTaskSuccessResult::NetworkContextCreated { context, .. } = result else { + panic!("expected a newly created network context"); + }; + assert!( + Arc::ptr_eq(&prompt, &context.secret_prompt()), + "the new context must retain the source prompt host identity" + ); + assert!( + context.wallet_backend().is_err(), + "the prompt must be present before lazy wallet-backend wiring" + ); + } + #[test] fn dapi_success_skips_availability_inspection() { let inspected = std::cell::Cell::new(false); diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index 34d4cff09..a894d28a4 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -238,6 +238,7 @@ impl AsyncTool for NetworkSwitch { spv_started, .. } => { + context.install_secret_prompt(ctx.secret_prompt()); // S5: drain the OUTGOING context's wallet backend before // replacing it. The old `ctx` (still in scope above) is the // context that is being evicted; the new `context` is the one @@ -259,3 +260,41 @@ impl AsyncTool for NetworkSwitch { } } } + +#[cfg(all(test, feature = "mcp"))] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::context::test_support::test_app_context; + use crate::wallet_backend::SecretPrompt; + use crate::wallet_backend::secret_prompt::test_support::TestPrompt; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn network_switch_tool_preserves_secret_prompt_identity() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(tmp.path()); + let prompt: Arc = Arc::new(TestPrompt::never()); + ctx.install_secret_prompt(Arc::clone(&prompt)); + let shared = Arc::new(arc_swap::ArcSwap::from(ctx)); + let service = DashMcpService::new_shared(shared); + + NetworkSwitch::invoke( + &service, + NetworkSwitchParams { + network: "mainnet".to_owned(), + }, + ) + .await + .expect("switch network through MCP"); + + let switched = service.tool_ctx().await.expect("switched context"); + assert!( + Arc::ptr_eq(&prompt, &switched.secret_prompt()), + "the MCP-swapped context must retain the source prompt host identity" + ); + if let Ok(backend) = switched.wallet_backend() { + backend.shutdown().await; + } + } +} From 79926f2f129e3764a44897bc82cd79ceb7e1851b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:09:46 +0000 Subject: [PATCH 2/9] fix(masternodes): open key protection prompt directly Route the aggregate masternode protection action through a KeyInfoScreen constructor that starts the add-protection confirmation flow. Keep the protection section expanded while that flow is active so the next step stays visible. Co-Authored-By: Codex Sol (GPT-5) --- src/ui/identities/keys/key_info_screen.rs | 13 +++ src/ui/masternodes/detail_screen.rs | 22 +++++- tests/kittest/masternode_tab.rs | 96 ++++++++++++++++++++++- 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 451d70ef7..1a3b083e6 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -790,6 +790,18 @@ impl KeyInfoScreen { } } + /// Build a key-info screen with the add-protection confirmation already open. + pub fn new_with_protection_prompt( + identity: QualifiedIdentity, + key: IdentityPublicKey, + private_key_data: Option<(PrivateKeyData, Option)>, + app_context: &Arc, + ) -> Self { + let mut screen = Self::new(identity, key, private_key_data, app_context); + screen.open_add_confirm(); + screen + } + fn validate_and_store_private_key(&mut self) { // Convert the input string to bytes (hex decoding) let private_key_bytes = match hex::decode(self.private_key_input.text()) { @@ -1099,6 +1111,7 @@ impl KeyInfoScreen { egui::CollapsingHeader::new("Key Protection") .default_open(false) + .open((self.protection_stage != ProtectionStage::Idle).then_some(true)) .show(ui, |ui| { let status_text = match status { IdentityProtectionStatus::Unprotected => { diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index f5e3421a4..0cab1b276 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -659,7 +659,7 @@ impl MasternodeDetailView { && let Some((target, key)) = self.first_protectable_key() && ui.button("Add password protection…").clicked() { - action = Some(self.open_key_info(target, &key)); + action = Some(self.open_key_info_with_protection_prompt(target, &key)); } action } @@ -714,6 +714,26 @@ impl MasternodeDetailView { ))) } + /// Open `KeyInfoScreen` directly in the add-protection confirmation flow. + fn open_key_info_with_protection_prompt( + &self, + target: PrivateKeyTarget, + key: &dash_sdk::platform::IdentityPublicKey, + ) -> AppAction { + let holding = self + .identity + .private_keys + .get_cloned_private_key_data_and_wallet_info(&(target, key.id())); + AppAction::AddScreen(Screen::KeyInfoScreen( + KeyInfoScreen::new_with_protection_prompt( + self.identity.clone(), + key.clone(), + holding, + &self.app_context, + ), + )) + } + /// Render the collapsible DPNS voting section (collapsed by default, /// open-contest count in the header). Inline voting reuses the existing /// `vote_on_dpns_name` backend (locked decision #1 — not a deep-link). diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 3439153cc..7c123f15c 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -3,14 +3,18 @@ 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::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; +use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; +use dash_evo_tool::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, +}; use dash_evo_tool::model::user_role::UserRole; use dash_evo_tool::ui::{RootScreenType, ScreenLike}; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::platform::Identifier; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; use egui_kittest::kittest::Queryable; use std::collections::BTreeMap; use std::sync::Arc; @@ -134,6 +138,47 @@ fn seed_node_with_voter_key(app_context: &Arc, byte: u8, alias: &str .expect("seed node-with-voter-key insert"); } +/// Seed a masternode with one locally held key. Insertion migrates the clear +/// key into the keyless vault, matching a loaded node with no password. +fn seed_node_with_unprotected_held_key(app_context: &Arc, byte: u8, alias: &str) { + let pv = PlatformVersion::latest(); + let key = IdentityPublicKey::random_key(1, Some(1), pv); + let identity = Identity::new_with_id_and_keys( + Identifier::from([byte; 32]), + BTreeMap::from([(key.id(), key.clone())]), + pv, + ) + .expect("masternode identity with key"); + let private_keys = KeyStorage { + private_keys: BTreeMap::from([( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::Clear([byte; 32]), + ), + )]), + }; + let node_qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some(alias.to_string()), + private_keys, + 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(&node_qi, &None) + .expect("seed node-with-unprotected-key insert"); +} + /// TC-FR1-01…04 — the Masternodes nav entry is absent below the Power role and /// present at Power or above. Raising the role flips the gate; the nav rail /// re-evaluates the per-entry `FeatureGate::Masternodes` skip each frame. @@ -787,3 +832,48 @@ fn manage_keys_button_opens_key_info_screen() { ); }); } + +/// Clicking the aggregate protection CTA opens `KeyInfoScreen` with its +/// confirmation dialog already visible, without a second click in that screen. +#[test] +fn add_password_protection_opens_confirmation_dialog() { + use dash_evo_tool::ui::Screen; + + 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_with_unprotected_held_key(&app_context, 0x72, "mn-protect-01"); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-protect-01").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("Add password protection…").is_some(), + "an unprotected vault key must offer the aggregate protection action" + ); + + harness.get_by_label("Add password protection…").click(); + harness.run_steps(3); + + assert!( + matches!( + harness.state().screen_stack.last(), + Some(Screen::KeyInfoScreen(_)) + ), + "the aggregate protection action must push KeyInfoScreen" + ); + assert!( + harness + .query_by_label("Protect this identity's keys with a password?") + .is_some(), + "the protection confirmation dialog must be visible immediately" + ); + assert!( + harness.query_by_label("Yes, add protection").is_some(), + "the visible dialog must expose its confirmation action" + ); + }); +} From cb6082074c2844e45efc09c21889378b6412c0e7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:02:34 +0000 Subject: [PATCH 3/9] fix(masternodes): reset detail view to list on root navigation set_main_screen() only called refresh_on_arrival(), which intentionally preserves nested view state for the return-from-sub-screen case. That meant clicking the left-nav or top-nav "Masternodes" entry re-rendered whatever masternode was last viewed instead of the list, since nothing ever reset MasternodesScreen's sticky `view` field back to List. Add a default no-op ScreenLike::reset_to_root_view() (delegated through the Screen enum), implement it on MasternodesScreen to reset to MasternodesView::List, and call it from set_main_screen() only -- not from refresh_on_arrival() itself, which stays untouched so returning from a pushed KeyInfoScreen/ClaimTokensScreen still preserves the detail view, and on_leave()'s existing "keep the view, drop secrets" contract is unaffected. Co-Authored-By: Codex Sol (GPT-5) Co-Authored-By: Claude --- src/app.rs | 4 +- src/ui/masternodes/list_screen.rs | 4 ++ src/ui/mod.rs | 7 +++ tests/kittest/masternode_tab.rs | 76 +++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 21f874d64..493d6958a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1956,7 +1956,9 @@ impl AppState { return; } self.select_main_screen(root_screen_type); - self.active_root_screen_mut().refresh_on_arrival(); + let active_screen = self.active_root_screen_mut(); + active_screen.reset_to_root_view(); + active_screen.refresh_on_arrival(); self.current_app_context() .update_settings(root_screen_type) .ok(); diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index f658293f1..aecdf5a97 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -516,6 +516,10 @@ impl ScreenLike for MasternodesScreen { self.reconcile_pending_load(); } + fn reset_to_root_view(&mut self) { + self.view = MasternodesView::List; + } + /// Drop every secret the open view holds — the load form's keys and /// encryption password, the detail view's unsubmitted voting key. This screen /// is a root screen: it lives for the whole process, so nothing else would diff --git a/src/ui/mod.rs b/src/ui/mod.rs index bd0496b6b..046be0970 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -770,6 +770,9 @@ pub trait ScreenLike { self.refresh() } + /// Reset a persistent root screen to the view selected by its navigation entry. + fn reset_to_root_view(&mut self) {} + /// Called by `AppState` when this root screen stops being the selected one. /// /// The counterpart of [`refresh_on_arrival`](ScreenLike::refresh_on_arrival). @@ -1087,6 +1090,10 @@ impl ScreenLike for Screen { delegate_to_screen!(self, screen => screen.refresh_on_arrival()) } + fn reset_to_root_view(&mut self) { + delegate_to_screen!(self, screen => screen.reset_to_root_view()) + } + fn on_leave(&mut self) { delegate_to_screen!(self, screen => screen.on_leave()) } diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 7c123f15c..b6f741d59 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -15,6 +15,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use egui::accesskit::Role; use egui_kittest::kittest::Queryable; use std::collections::BTreeMap; use std::sync::Arc; @@ -877,3 +878,78 @@ fn add_password_protection_opens_confirmation_dialog() { ); }); } + +#[test] +fn left_nav_return_to_masternodes_resets_detail_to_list() { + 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, + 0x72, + "mn-nav-reset-01", + IdentityType::Masternode, + ); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-nav-reset-01").click(); + harness.run_steps(3); + assert!(harness.query_by_label("‹ All masternodes").is_some()); + + harness + .get_by_role_and_label(Role::Button, "Wallets") + .click(); + harness.run_steps(3); + harness + .get_by_role_and_label(Role::Button, "Masternodes") + .click(); + harness.run_steps(3); + + assert!( + harness.query_by_label("Open mn-nav-reset-01").is_some(), + "returning through the left nav must show the masternode list" + ); + assert!(harness.query_by_label("‹ All masternodes").is_none()); + }); +} + +#[test] +fn go_to_main_screen_from_key_info_preserves_masternode_detail() { + use dash_evo_tool::ui::Screen; + + 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_with_voter_key(&app_context, 0x73, "mn-nav-back-01"); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-nav-back-01").click(); + harness.run_steps(3); + harness.get_by_label("Voting key ›").click(); + harness.run_steps(3); + assert!(matches!( + harness.state().screen_stack.last(), + Some(Screen::KeyInfoScreen(_)) + )); + + harness + .query_all_by_role_and_label(Role::Button, "Identities") + .min_by(|left, right| left.rect().top().total_cmp(&right.rect().top())) + .expect("Key Info breadcrumb") + .click(); + harness.run_steps(3); + + assert!(harness.state().screen_stack.is_empty()); + assert!( + harness.query_by_label("‹ All masternodes").is_some(), + "returning from Key Info must preserve the open detail view" + ); + assert!(harness.query_by_label("Open mn-nav-back-01").is_none()); + }); +} From 8f12b7b64b6a9f9f79011d0eccb0b74591ade535 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:41:41 +0000 Subject: [PATCH 4/9] fix(masternodes): guard reset_to_root_view against a pending load Co-Authored-By: Codex Sol --- src/ui/masternodes/list_screen.rs | 4 +- tests/kittest/masternode_tab.rs | 139 +++++++++++++++++++++++++++++- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index aecdf5a97..df54b9669 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -517,7 +517,9 @@ impl ScreenLike for MasternodesScreen { } fn reset_to_root_view(&mut self) { - self.view = MasternodesView::List; + if self.pending_load.is_none() { + self.view = MasternodesView::List; + } } /// Drop every secret the open view holds — the load form's keys and diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index b6f741d59..393285c74 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -13,10 +13,11 @@ use dash_evo_tool::ui::{RootScreenType, ScreenLike}; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use egui::accesskit::Role; -use egui_kittest::kittest::Queryable; +use egui::accesskit::{Role, Toggled}; +use egui_kittest::kittest::{NodeT, Queryable}; use std::collections::BTreeMap; use std::sync::Arc; @@ -916,6 +917,140 @@ fn left_nav_return_to_masternodes_resets_detail_to_list() { }); } +#[test] +fn active_masternodes_left_nav_resets_detail_to_list() { + 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, + 0x74, + "mn-active-nav-reset-01", + IdentityType::Masternode, + ); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-active-nav-reset-01").click(); + harness.run_steps(3); + assert!(harness.query_by_label("‹ All masternodes").is_some()); + + harness + .get_by_role_and_label(Role::Button, "Masternodes") + .click(); + harness.run_steps(3); + + assert!( + harness + .query_by_label("Open mn-active-nav-reset-01") + .is_some(), + "clicking the already-active Masternodes nav entry must show the list" + ); + assert!(harness.query_by_label("‹ All masternodes").is_none()); + }); +} + +#[test] +fn left_nav_return_preserves_pending_masternode_load_form_fields() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .max_blocking_threads(1) + .build() + .expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + let (blocking_release, blocking_wait) = std::sync::mpsc::channel::<()>(); + let _blocking_task = tokio::task::spawn_blocking(move || blocking_wait.recv()); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Load a masternode").click(); + harness.set_size(egui::vec2(1280.0, 1200.0)); + harness.run_steps(3); + + harness.get_by_label("Evonode").click(); + harness.run_steps(2); + assert_eq!( + harness.get_by_label("Evonode").accesskit_node().toggled(), + Some(Toggled::True), + "the non-default node type must be selected before submission" + ); + + let identity_id = Identifier::from([0x75; 32]); + let pro_tx_hash = identity_id.to_string(Encoding::Hex); + harness + .query_all_by_role(Role::TextInput) + .next() + .expect("ProTxHash input") + .focus(); + harness.event(egui::Event::Text(pro_tx_hash.clone())); + harness.step(); + + let alias = "mn-pending-nav-01"; + harness + .query_all_by_role(Role::TextInput) + .nth(1) + .expect("alias input") + .focus(); + harness.event(egui::Event::Text(alias.to_owned())); + harness.step(); + assert!( + harness.query_all_by_value(&pro_tx_hash).count() >= 1, + "the ProTxHash must be entered before submission" + ); + assert!( + harness.query_all_by_value(alias).count() >= 1, + "the alias must be entered before submission" + ); + + harness.get_by_label("Load masternode").click(); + harness.step(); + assert!( + app_context + .mark_identity_load_submitted(identity_id) + .is_none(), + "the load must be pending before navigation" + ); + + harness + .get_by_role_and_label(Role::Button, "Wallets") + .click(); + harness.run_steps(3); + harness + .get_by_role_and_label(Role::Button, "Masternodes") + .click(); + harness.run_steps(3); + + assert!( + harness.query_by_label("ProTxHash").is_some(), + "returning while a load is pending must keep the load form open" + ); + assert!( + harness.query_all_by_value(&pro_tx_hash).count() >= 1, + "the pending load must retain its entered ProTxHash" + ); + assert!( + harness.query_all_by_value(alias).count() >= 1, + "the pending load must retain its entered alias" + ); + assert_eq!( + harness.get_by_label("Evonode").accesskit_node().toggled(), + Some(Toggled::True), + "the pending load must retain its entered node type" + ); + + drop(blocking_release); + for _ in 0..10 { + harness.step(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }); +} + #[test] fn go_to_main_screen_from_key_info_preserves_masternode_detail() { use dash_evo_tool::ui::Screen; From 1102bdfc48311da55890b84ebe6a83c6594a79e0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:41:47 +0000 Subject: [PATCH 5/9] fix(identities): re-validate protection status before auto-opening key protection dialog Co-Authored-By: Codex Sol --- src/ui/identities/keys/key_info_screen.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 1a3b083e6..1d308c48a 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -790,7 +790,9 @@ impl KeyInfoScreen { } } - /// Build a key-info screen with the add-protection confirmation already open. + /// Build a key-info screen with the add-protection confirmation already open + /// when vault-backed protection is available, or show a warning when wallet + /// setup has not made protection available yet. pub fn new_with_protection_prompt( identity: QualifiedIdentity, key: IdentityPublicKey, @@ -798,7 +800,18 @@ impl KeyInfoScreen { app_context: &Arc, ) -> Self { let mut screen = Self::new(identity, key, private_key_data, app_context); - screen.open_add_confirm(); + let status = screen.compute_protection_status(); + if status == IdentityProtectionStatus::NoVaultKeys { + screen.protection_stage = ProtectionStage::Idle; + MessageBanner::set_global( + app_context.egui_ctx(), + "Password protection is not available yet. Wait for wallet setup to finish, then try again.", + MessageType::Warning, + ); + } else { + screen.protection_status = Some(status); + screen.open_add_confirm(); + } screen } @@ -1111,6 +1124,7 @@ impl KeyInfoScreen { egui::CollapsingHeader::new("Key Protection") .default_open(false) + // Keep the header open so an active protection form or dialog remains visible. .open((self.protection_stage != ProtectionStage::Idle).then_some(true)) .show(ui, |ui| { let status_text = match status { From a7256a9af8cdf9b9e8c3173db6b5a78b557609c5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:41:54 +0000 Subject: [PATCH 6/9] fix(context): recover poisoned lock in install_secret_prompt Co-Authored-By: Codex Sol --- src/context/mod.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index 70e215fd4..2f2611df1 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1146,9 +1146,13 @@ impl AppContext { /// [`NullSecretPrompt`], which surfaces `SecretPromptUnavailable` for any /// passphrase-protected scope. pub fn install_secret_prompt(&self, prompt: Arc) { - if let Ok(mut guard) = self.secret_prompt.0.lock() { - *guard = prompt; - } + let mut guard = self + .secret_prompt + .0 + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + *guard = prompt; + self.secret_prompt.0.clear_poison(); } /// The currently-installed secret-prompt host. Falls back to the headless @@ -1510,6 +1514,27 @@ pub(crate) const fn default_platform_version(_network: &Network) -> &'static Pla mod tests { use super::*; + #[test] + fn install_secret_prompt_recovers_poisoned_slot() { + use crate::context::test_support::test_app_context; + use crate::wallet_backend::secret_prompt::test_support::TestPrompt; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = test_app_context(tmp.path()); + let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = ctx.secret_prompt.0.lock().expect("prompt slot lock"); + panic!("poison prompt slot"); + })); + assert!(poisoned.is_err(), "the prompt slot must be poisoned"); + + let prompt: Arc = Arc::new(TestPrompt::never()); + ctx.install_secret_prompt(Arc::clone(&prompt)); + assert!( + Arc::ptr_eq(&prompt, &ctx.secret_prompt()), + "install_secret_prompt must replace the host after lock poisoning" + ); + } + #[test] fn wallet_name_with_spaces_is_url_encoded() { let base = "http://127.0.0.1:9998"; From 3ad70edc4109486697b5600e2c2e876cf56a082d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:42:05 +0000 Subject: [PATCH 7/9] refactor: remove redundant secret-prompt installs, strengthen regression tests Co-Authored-By: Codex Sol --- src/app.rs | 9 -------- src/backend_task/mod.rs | 47 +++++++++++++++++++++++++++++++--------- src/mcp/tools/network.rs | 47 +++++++++++++++++++++++++++++++--------- 3 files changed, 74 insertions(+), 29 deletions(-) diff --git a/src/app.rs b/src/app.rs index 493d6958a..1d75f3498 100644 --- a/src/app.rs +++ b/src/app.rs @@ -795,9 +795,6 @@ pub struct AppState { /// MCP configuration held until a required boot-time network selection succeeds. #[cfg(feature = "mcp")] mcp_server_pending_config: Option, - /// The egui secret prompt host, kept so newly-created (on-demand) network - /// contexts can have it installed before their backend is wired. - secret_prompt_host: Arc, /// Receives just-in-time passphrase requests enqueued by the egui secret /// prompt host. Drained once per frame in [`Self::update`]; the active /// request becomes [`Self::active_secret_prompt`]. @@ -1467,7 +1464,6 @@ impl AppState { mcp_app_context, #[cfg(feature = "mcp")] mcp_server_pending_config, - secret_prompt_host, secret_prompt_receiver, active_secret_prompt: None, prompt_was_blocking: false, @@ -2227,11 +2223,6 @@ impl App for AppState { context, .. } => { - // Install the egui prompt host before the new - // context's backend is wired, so its `SecretAccess` - // gets the interactive host rather than the headless - // default. - context.install_secret_prompt(Arc::clone(&self.secret_prompt_host)); self.network_contexts.insert(network, context); self.network_switch_pending = None; self.network_switch_banner.take_and_clear(); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 384a136df..8f1186e01 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -1281,13 +1281,25 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn switch_network_propagates_secret_prompt_before_wallet_backend_wiring() { use crate::context::test_support::test_app_context; - use crate::wallet_backend::SecretPrompt; - use crate::wallet_backend::secret_prompt::test_support::TestPrompt; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::wallet_backend::secret_prompt::SecretScope; + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + use crate::wallet_backend::{IdentityKeyView, SecretPrompt}; + use platform_wallet_storage::secrets::SecretString; let tmp = tempfile::tempdir().expect("tempdir"); let ctx = test_app_context(tmp.path()); - let prompt: Arc = Arc::new(TestPrompt::never()); - ctx.install_secret_prompt(Arc::clone(&prompt)); + let identity_id = [0x91; 32]; + let target = PrivateKeyTarget::PrivateKeyOnMainIdentity; + let key_id = 7; + let key = [0xa5; 32]; + let password = "network-switch-password"; + let secret_store = ctx.secret_store(); + IdentityKeyView::new(&secret_store, identity_id) + .store_protected(&target, key_id, &key, &SecretString::new(password)) + .expect("store protected identity key"); + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(password)])); + ctx.install_secret_prompt(Arc::clone(&prompt) as Arc); let (tx, _rx) = tokio::sync::mpsc::channel::(32); let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); @@ -1295,7 +1307,7 @@ mod tests { .run_backend_task( BackendTask::SwitchNetwork { network: Network::Mainnet, - start_spv: false, + start_spv: true, }, sender, ) @@ -1305,14 +1317,29 @@ mod tests { let BackendTaskSuccessResult::NetworkContextCreated { context, .. } = result else { panic!("expected a newly created network context"); }; + let backend = context.wallet_backend().expect("switched backend wired"); + let scope = SecretScope::IdentityKey { + identity_id, + target, + key_id, + }; + let resolved = backend + .secret_access() + .with_secret(&scope, |plaintext| { + Ok(plaintext.expose_identity_key().copied() == Some(key)) + }) + .await + .expect("resolve protected key through switched backend"); assert!( - Arc::ptr_eq(&prompt, &context.secret_prompt()), - "the new context must retain the source prompt host identity" + resolved, + "the wired backend must resolve through the source interactive prompt" ); - assert!( - context.wallet_backend().is_err(), - "the prompt must be present before lazy wallet-backend wiring" + assert_eq!( + prompt.ask_count(), + 1, + "the switched backend must prompt once" ); + backend.shutdown().await; } #[test] diff --git a/src/mcp/tools/network.rs b/src/mcp/tools/network.rs index a894d28a4..e832ef034 100644 --- a/src/mcp/tools/network.rs +++ b/src/mcp/tools/network.rs @@ -238,7 +238,6 @@ impl AsyncTool for NetworkSwitch { spv_started, .. } => { - context.install_secret_prompt(ctx.secret_prompt()); // S5: drain the OUTGOING context's wallet backend before // replacing it. The old `ctx` (still in scope above) is the // context that is being evicted; the new `context` is the one @@ -267,15 +266,27 @@ mod tests { use super::*; use crate::context::test_support::test_app_context; - use crate::wallet_backend::SecretPrompt; - use crate::wallet_backend::secret_prompt::test_support::TestPrompt; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::wallet_backend::secret_prompt::SecretScope; + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + use crate::wallet_backend::{IdentityKeyView, SecretPrompt}; + use platform_wallet_storage::secrets::SecretString; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn network_switch_tool_preserves_secret_prompt_identity() { let tmp = tempfile::tempdir().expect("tempdir"); let ctx = test_app_context(tmp.path()); - let prompt: Arc = Arc::new(TestPrompt::never()); - ctx.install_secret_prompt(Arc::clone(&prompt)); + let identity_id = [0x92; 32]; + let target = PrivateKeyTarget::PrivateKeyOnMainIdentity; + let key_id = 8; + let key = [0xb6; 32]; + let password = "mcp-network-switch-password"; + let secret_store = ctx.secret_store(); + IdentityKeyView::new(&secret_store, identity_id) + .store_protected(&target, key_id, &key, &SecretString::new(password)) + .expect("store protected identity key"); + let prompt = Arc::new(TestPrompt::new([ScriptedAnswer::once(password)])); + ctx.install_secret_prompt(Arc::clone(&prompt) as Arc); let shared = Arc::new(arc_swap::ArcSwap::from(ctx)); let service = DashMcpService::new_shared(shared); @@ -289,12 +300,28 @@ mod tests { .expect("switch network through MCP"); let switched = service.tool_ctx().await.expect("switched context"); + let backend = switched.wallet_backend().expect("switched backend wired"); + let scope = SecretScope::IdentityKey { + identity_id, + target, + key_id, + }; + let resolved = backend + .secret_access() + .with_secret(&scope, |plaintext| { + Ok(plaintext.expose_identity_key().copied() == Some(key)) + }) + .await + .expect("resolve protected key through MCP-switched backend"); assert!( - Arc::ptr_eq(&prompt, &switched.secret_prompt()), - "the MCP-swapped context must retain the source prompt host identity" + resolved, + "the MCP-swapped backend must resolve through the source interactive prompt" ); - if let Ok(backend) = switched.wallet_backend() { - backend.shutdown().await; - } + assert_eq!( + prompt.ask_count(), + 1, + "the MCP-switched backend must prompt once" + ); + backend.shutdown().await; } } From d557b6b27e492e7893f8f5d6f20daca1bf4ed5aa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:42:19 +0000 Subject: [PATCH 8/9] refactor(masternodes): dedup key-info construction and document root reset Co-Authored-By: Codex Sol --- src/ui/masternodes/detail_screen.rs | 44 +++++++++++++++++------------ src/ui/mod.rs | 3 ++ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 0cab1b276..311b34535 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -236,6 +236,11 @@ pub enum DetailOutcome { Forward(Box), } +enum KeyInfoOpenMode { + Normal, + WithProtectionPrompt, +} + /// Masternode/evonode detail view state. pub struct MasternodeDetailView { app_context: Arc, @@ -702,16 +707,7 @@ impl MasternodeDetailView { target: PrivateKeyTarget, key: &dash_sdk::platform::IdentityPublicKey, ) -> AppAction { - let holding = self - .identity - .private_keys - .get_cloned_private_key_data_and_wallet_info(&(target, key.id())); - AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( - self.identity.clone(), - key.clone(), - holding, - &self.app_context, - ))) + self.open_key_info_with_mode(target, key, KeyInfoOpenMode::Normal) } /// Open `KeyInfoScreen` directly in the add-protection confirmation flow. @@ -719,19 +715,31 @@ impl MasternodeDetailView { &self, target: PrivateKeyTarget, key: &dash_sdk::platform::IdentityPublicKey, + ) -> AppAction { + self.open_key_info_with_mode(target, key, KeyInfoOpenMode::WithProtectionPrompt) + } + + fn open_key_info_with_mode( + &self, + target: PrivateKeyTarget, + key: &dash_sdk::platform::IdentityPublicKey, + mode: KeyInfoOpenMode, ) -> AppAction { let holding = self .identity .private_keys .get_cloned_private_key_data_and_wallet_info(&(target, key.id())); - AppAction::AddScreen(Screen::KeyInfoScreen( - KeyInfoScreen::new_with_protection_prompt( - self.identity.clone(), - key.clone(), - holding, - &self.app_context, - ), - )) + let identity = self.identity.clone(); + let key = key.clone(); + let screen = match mode { + KeyInfoOpenMode::Normal => { + KeyInfoScreen::new(identity, key, holding, &self.app_context) + } + KeyInfoOpenMode::WithProtectionPrompt => { + KeyInfoScreen::new_with_protection_prompt(identity, key, holding, &self.app_context) + } + }; + AppAction::AddScreen(Screen::KeyInfoScreen(screen)) } /// Render the collapsible DPNS voting section (collapsed by default, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 046be0970..82417da35 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -771,6 +771,9 @@ pub trait ScreenLike { } /// Reset a persistent root screen to the view selected by its navigation entry. + /// Called only by `AppState::set_main_screen` on root navigation, not by + /// [`refresh_on_arrival`](ScreenLike::refresh_on_arrival) or [`on_leave`](ScreenLike::on_leave), + /// so returning from a pushed sub-screen preserves the current sub-view. fn reset_to_root_view(&mut self) {} /// Called by `AppState` when this root screen stops being the selected one. From f22323766c905940e8c79d7914e7dfdf77c47dd0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:00:42 +0000 Subject: [PATCH 9/9] fix(masternodes): preserve failed load form on navigation Gate root-view resets on the visible load form so a failed load remains available for correction after its pending task state has cleared. Co-Authored-By: Codex GPT-5 --- src/ui/masternodes/list_screen.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index df54b9669..8a3c174b4 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -517,7 +517,7 @@ impl ScreenLike for MasternodesScreen { } fn reset_to_root_view(&mut self) { - if self.pending_load.is_none() { + if !matches!(self.view, MasternodesView::Load(_)) { self.view = MasternodesView::List; } } @@ -880,6 +880,12 @@ mod tests { "submit must re-enable after a failed load" ); + screen.reset_to_root_view(); + assert!( + matches!(screen.view, MasternodesView::Load(_)), + "root navigation must preserve a failed load form for correction" + ); + // Resubmit, then succeed: the form closes and drops back to the list. submit_load(&mut screen, target); claim_dispatched_load(&ctx, &screen).loaded();