diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index aa129078d..7dd2576df 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -14,8 +14,8 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::components::{MessageBanner, ResultBannerExt}; -use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::identities::{auto_selected_wallet_or_banner, get_selected_wallet}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, Screen, ScreenLike, ScreenType}; use dash_sdk::dpp::document::DocumentV0Getters; @@ -115,10 +115,10 @@ impl ContactRequests { .id() .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); - // Get wallet for the selected identity - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = auto_selected_wallet_or_banner( + app_context.egui_ctx(), + get_selected_wallet(&preferred, Some(&app_context), None), + ); } new_self diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index b75802837..0f03be5a4 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -17,7 +17,7 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::helpers::{ModalOpeningGuard, clicked_outside_window_after_open}; -use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::{auto_selected_wallet_or_banner, get_selected_wallet}; use crate::ui::state::AvatarCache; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -134,9 +134,10 @@ impl ProfileScreen { new_self.selected_identity_string ); - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = auto_selected_wallet_or_banner( + app_context.egui_ctx(), + get_selected_wallet(&preferred, Some(&app_context), None), + ); } new_self diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 28dc50e10..08cbce87d 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -16,7 +16,7 @@ use crate::ui::components::wallet_unlock_popup::{ use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; use crate::ui::identities::funding_common::generate_qr_code_image; -use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::{auto_selected_wallet_or_banner, get_selected_wallet}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use eframe::epaint::TextureHandle; @@ -82,10 +82,10 @@ impl QRCodeGeneratorScreen { new_self.selected_identity = Some(preferred.clone()); new_self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); - // Get wallet for the selected identity - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = auto_selected_wallet_or_banner( + app_context.egui_ctx(), + get_selected_wallet(&preferred, Some(&app_context), None), + ); } new_self diff --git a/src/ui/identities/mod.rs b/src/ui/identities/mod.rs index d582aa176..803ee18d7 100644 --- a/src/ui/identities/mod.rs +++ b/src/ui/identities/mod.rs @@ -1,8 +1,11 @@ +use std::error::Error as StdError; +use std::fmt; use std::sync::{Arc, RwLock}; use dash_sdk::{ dpp::{ data_contract::accessors::v0::DataContractV0Getters, + data_contract::errors::DataContractError, identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0, }, platform::IdentityPublicKey, @@ -16,6 +19,7 @@ use crate::{ }, wallet::Wallet, }, + ui::{MessageType, components::MessageBanner}, }; pub mod add_existing_identity_screen; @@ -28,6 +32,81 @@ pub mod top_up_identity_screen; pub mod transfer_screen; pub mod withdraw_screen; +#[derive(Debug)] +pub enum SelectedWalletError { + DpnsPreorderDocumentTypeNotFound { + source: DataContractError, + }, + MissingDocumentSigningKey { + identity_label: Option, + identity_id: String, + }, + NoKeyProvided, +} + +impl fmt::Display for SelectedWalletError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DpnsPreorderDocumentTypeNotFound { .. } => { + write!(f, "DPNS preorder document type not found") + } + Self::MissingDocumentSigningKey { + identity_label, + identity_id, + } => { + if let Some(identity_label) = identity_label { + write!( + f, + "Identity {identity_label} ({identity_id}) cannot sign this action \ + because it has no high-security signing key. Add a high-security \ + signing key to this identity, or choose a different identity." + ) + } else { + write!( + f, + "Identity {identity_id} cannot sign this action because it has no \ + high-security signing key. Add a high-security signing key to this \ + identity, or choose a different identity." + ) + } + } + Self::NoKeyProvided => write!(f, "No key provided when getting selected wallet"), + } + } +} + +impl StdError for SelectedWalletError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::DpnsPreorderDocumentTypeNotFound { source } => Some(source), + Self::MissingDocumentSigningKey { .. } | Self::NoKeyProvided => None, + } + } +} + +/// Returns `true` if the given error is the expected "identity has no +/// document-signing key" error from [`get_selected_wallet`]. +pub fn is_missing_document_signing_key_error(error: &SelectedWalletError) -> bool { + matches!(error, SelectedWalletError::MissingDocumentSigningKey { .. }) +} + +/// Applies the auto-selection policy for screen construction: +/// suppress the expected missing-document-signing-key error for +/// auto-selected identities, but surface every other failure. +pub fn auto_selected_wallet_or_banner( + ctx: &egui::Context, + result: Result>>, SelectedWalletError>, +) -> Option>> { + match result { + Ok(wallet) => wallet, + Err(error) if is_missing_document_signing_key_error(&error) => None, + Err(error) => { + MessageBanner::set_global(ctx, &error, MessageType::Error); + None + } + } +} + /// Retrieves the appropriate wallet (if any) associated with the given identity. /// /// # Description @@ -52,8 +131,8 @@ pub mod withdraw_screen; /// # Returns /// /// Returns `Ok(Some(Arc>))` if a matching wallet is found, -/// `Ok(None)` if no wallet is associated with the key, or `Err(String)` if -/// an error is encountered. +/// `Ok(None)` if no wallet is associated with the key, or +/// `Err(SelectedWalletError)` if an error is encountered. /// /// # Errors /// @@ -64,7 +143,7 @@ pub fn get_selected_wallet( qualified_identity: &QualifiedIdentity, app_context: Option<&AppContext>, selected_key: Option<&IdentityPublicKey>, -) -> Result>>, String> { +) -> Result>>, SelectedWalletError> { // If `app_context` is provided, use the DPNS-based approach. let public_key = if let Some(context) = app_context { let dpns_contract = &context.dpns_contract; @@ -72,18 +151,28 @@ pub fn get_selected_wallet( // Attempt to fetch the `preorder` document type from the DPNS contract. let preorder_document_type = dpns_contract .document_type_for_name("preorder") - .map_err(|e| format!("DPNS preorder document type not found: {}", e))?; + .map_err(|source| SelectedWalletError::DpnsPreorderDocumentTypeNotFound { source })?; // Attempt to retrieve the public key from the identity. qualified_identity .document_signing_key(&preorder_document_type) .ok_or_else(|| { - "Identity doesn't have an authentication key for signing document transitions" - .to_string() + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let identity_label = qualified_identity.alias.as_deref().or_else(|| { + qualified_identity + .dpns_names + .first() + .map(|n| n.name.as_str()) + }); + SelectedWalletError::MissingDocumentSigningKey { + identity_label: identity_label.map(str::to_owned), + identity_id: qualified_identity.identity.id().to_string(Encoding::Base58), + } })? } else { // Fallback: directly use the provided selected key. - selected_key.ok_or_else(|| "No key provided when getting selected wallet".to_string())? + selected_key.ok_or(SelectedWalletError::NoKeyProvided)? }; // Once we have the public key (either from DPNS or directly), look up @@ -104,3 +193,66 @@ pub fn get_selected_wallet( Ok(None) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn missing_document_signing_key_error(identity_label: Option<&str>) -> SelectedWalletError { + SelectedWalletError::MissingDocumentSigningKey { + identity_label: identity_label.map(str::to_owned), + identity_id: "TestIdentityId".to_string(), + } + } + + #[test] + fn missing_document_signing_key_display_includes_label_when_present() { + let error = missing_document_signing_key_error(Some("Test Identity")); + + assert_eq!( + error.to_string(), + "Identity Test Identity (TestIdentityId) cannot sign this action because it has no \ + high-security signing key. Add a high-security signing key to this identity, or \ + choose a different identity." + ); + } + + #[test] + fn missing_document_signing_key_display_uses_identity_id_without_label() { + let error = missing_document_signing_key_error(None); + + assert_eq!( + error.to_string(), + "Identity TestIdentityId cannot sign this action because it has no high-security \ + signing key. Add a high-security signing key to this identity, or choose a \ + different identity." + ); + } + + #[test] + fn auto_selection_policy_suppresses_missing_document_signing_key() { + let ctx = egui::Context::default(); + + let wallet = + auto_selected_wallet_or_banner(&ctx, Err(missing_document_signing_key_error(None))); + + assert!(wallet.is_none()); + assert!( + !MessageBanner::has_global(&ctx), + "automatic selection must suppress the typed missing-document-signing-key banner" + ); + } + + #[test] + fn auto_selection_policy_surfaces_unrelated_selected_wallet_error() { + let ctx = egui::Context::default(); + + let wallet = auto_selected_wallet_or_banner(&ctx, Err(SelectedWalletError::NoKeyProvided)); + + assert!(wallet.is_none()); + assert!( + MessageBanner::has_global(&ctx), + "automatic selection must only suppress MissingDocumentSigningKey" + ); + } +} diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 515c29603..77fd6baea 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -264,7 +264,7 @@ impl SetTokenPriceScreen { let selected_wallet = get_selected_wallet(&identity_token_info.identity, None, possible_key).unwrap_or_else( |e| { - set_error_banner(&e); + set_error_banner(&e.to_string()); None }, ); diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs index ed2d056c7..634b6aeae 100644 --- a/src/ui/tokens/token_action_screen.rs +++ b/src/ui/tokens/token_action_screen.rs @@ -192,7 +192,7 @@ impl TokenActionScreen { let selected_wallet = get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) .unwrap_or_else(|e| { - super::set_error_banner(app_context, &e); + super::set_error_banner(app_context, &e.to_string()); None }); diff --git a/tests/kittest/dashpay_screen.rs b/tests/kittest/dashpay_screen.rs index 501fb4a0c..8cc6be7ea 100644 --- a/tests/kittest/dashpay_screen.rs +++ b/tests/kittest/dashpay_screen.rs @@ -13,6 +13,7 @@ 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::ScreenLike; +use dash_evo_tool::ui::components::MessageBanner; use dash_evo_tool::ui::dashpay::add_contact_screen::AddContactScreen; use dash_evo_tool::ui::dashpay::contact_requests::ContactRequests; use dash_evo_tool::ui::dashpay::contacts_list::ContactsList; @@ -27,7 +28,10 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::Identifier; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::cell::RefCell; use std::collections::BTreeMap; +use std::rc::Rc; use std::sync::Arc; /// On the Contacts subscreen, a `MissingEncryptionKey` error must route to @@ -166,6 +170,34 @@ fn contact_requests_defaults_to_app_scoped_identity() { }); } +/// `ContactRequests::new()` must suppress only the typed startup +/// `MissingDocumentSigningKey` selection banner for wallet-less identities. +#[test] +fn contact_requests_startup_suppresses_missing_document_signing_key_banner() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let selected = seed_dp_identity(&ctx, 0xD5, "CR Startup Missing Key"); + ctx.set_selected_identity(Some(selected)); + MessageBanner::clear_all_global(ctx.egui_ctx()); + + let screen = ContactRequests::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(selected), + "ContactRequests must still select the app-scoped identity at startup" + ); + assert!( + !MessageBanner::has_global(ctx.egui_ctx()), + "ContactRequests startup must suppress the typed missing-document-signing-key banner" + ); + }); +} + /// `PaymentHistory::new()` must default to the app-scoped identity. #[test] fn payment_history_defaults_to_app_scoped_identity() { @@ -214,6 +246,85 @@ fn profile_screen_defaults_to_app_scoped_identity() { }); } +/// `ProfileScreen::new()` must suppress only the typed startup +/// `MissingDocumentSigningKey` selection banner for wallet-less identities. +#[test] +fn profile_screen_startup_suppresses_missing_document_signing_key_banner() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let selected = seed_dp_identity(&ctx, 0x23, "PS Startup Missing Key"); + ctx.set_selected_identity(Some(selected)); + MessageBanner::clear_all_global(ctx.egui_ctx()); + + let screen = ProfileScreen::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(selected), + "ProfileScreen must still select the app-scoped identity at startup" + ); + assert!( + !MessageBanner::has_global(ctx.egui_ctx()), + "ProfileScreen startup must suppress the typed missing-document-signing-key banner" + ); + }); +} + +/// Explicit/user-initiated profile identity selection must still surface the +/// typed `MissingDocumentSigningKey` error through `ResultBannerExt`. +#[test] +fn profile_screen_user_selection_surfaces_missing_document_signing_key_banner() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let alpha = seed_dp_identity(&ctx, 0x24, "PS Explicit Alpha"); + let beta = seed_dp_identity(&ctx, 0x25, "PS Explicit Beta"); + ctx.set_selected_identity(Some(alpha)); + MessageBanner::clear_all_global(ctx.egui_ctx()); + + let screen = Rc::new(RefCell::new(ProfileScreen::new(ctx.clone()))); + assert!( + !MessageBanner::has_global(ctx.egui_ctx()), + "startup construction should not leave a banner before user selection" + ); + + let screen_for_ui = Rc::clone(&screen); + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 140.0)) + .build_ui(move |ui| { + let _ = screen_for_ui.borrow_mut().render(ui); + }); + + harness.step(); + assert!( + !MessageBanner::has_global(ctx.egui_ctx()), + "initial render should not publish the startup-suppressed error" + ); + + harness.get_by_value("PS Explicit Alpha").click(); + harness.run_steps(2); + harness.get_by_label("PS Explicit Beta").click(); + harness.run_steps(2); + + assert_eq!( + ctx.selected_identity_id(), + Some(beta), + "the picker interaction must be a real user-initiated selection" + ); + assert!( + MessageBanner::has_global(ctx.egui_ctx()), + "explicit identity selection must publish MissingDocumentSigningKey" + ); + }); +} + /// `QRCodeGeneratorScreen::new()` must default to the app-scoped identity. #[test] fn qr_generator_defaults_to_app_scoped_identity() { @@ -238,6 +349,34 @@ fn qr_generator_defaults_to_app_scoped_identity() { }); } +/// `QRCodeGeneratorScreen::new()` must suppress only the typed startup +/// `MissingDocumentSigningKey` selection banner for wallet-less identities. +#[test] +fn qr_generator_startup_suppresses_missing_document_signing_key_banner() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + let selected = seed_dp_identity(&ctx, 0x45, "QRG Startup Missing Key"); + ctx.set_selected_identity(Some(selected)); + MessageBanner::clear_all_global(ctx.egui_ctx()); + + let screen = QRCodeGeneratorScreen::new(ctx.clone()); + + assert_eq!( + screen.selected_identity.as_ref().map(|qi| qi.identity.id()), + Some(selected), + "QRCodeGeneratorScreen must still select the app-scoped identity at startup" + ); + assert!( + !MessageBanner::has_global(ctx.egui_ctx()), + "QRCodeGeneratorScreen startup must suppress the typed missing-document-signing-key banner" + ); + }); +} + /// `QRScannerScreen::new()` must default to the app-scoped identity. #[test] fn qr_scanner_defaults_to_app_scoped_identity() {