diff --git a/CLAUDE.md b/CLAUDE.md index 3f4cb4a2f..01763d36c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,19 @@ scripts/safe-cargo.sh +nightly fmt --all * When a method takes `&AppContext` (or `Option<&AppContext>`), place it as the first parameter after `self`. * Screen constructors handle errors internally via `MessageBanner` and return `Self` with degraded state. Keep `create_screen()` clean — no error handling at callsites. +### Error messages + +User-facing error messages (shown in `MessageBanner` via `Display`) must follow these rules: + +1. **Audience**: Write for the Everyday User persona (`docs/personas/everyday-user.md`). No jargon — no "consensus error", "nonce", "state transition", "SDK", "RPC", or error codes. +2. **Structure**: *What happened* + *what to do*. Every message must include a concrete action the user can take themselves: retry, wait, try a different approach. Never redirect to "contact support" — users must be able to self-resolve. +3. **Tone**: Calm, direct, brief. Not apologetic ("Sorry!"), not alarming ("Something went wrong!"), not vague ("An error occurred"). +4. **Technical details**: Never in the message itself — no raw error strings, stack traces, SDK internals, or error codes. Attach via `BannerHandle::with_details(e)` — the `Debug` repr goes to the collapsible details panel and logs. Never refer users to "details" or "details panel" — these are not visible in basic mode. Exception: Base58 identifiers (see rule 7) are not technical details — they are user-meaningful handles. +5. **i18n-ready**: Write messages as simple, complete sentences without interpolation tricks. Avoid concatenating fragments, positional assumptions, or grammar that breaks in other languages. Messages should be straightforward to extract into [Fluent](https://projectfluent.org/) `.ftl` files later — one message ID per string, placeholders only for dynamic values (`{ $seconds }`, `{ $name }`), no logic in the text itself. +6. **Reference implementation**: `sdk_error_user_message()` in `src/backend_task/error.rs` demonstrates the pattern for SDK errors. New `TaskError` variants should follow the same style. +7. **Base58 IDs are allowed in messages**: Contract IDs, identity IDs, document IDs, and similar Base58-encoded identifiers may appear in user-facing messages when they help the user identify which object is involved (e.g., *"This key conflicts with an existing key bound to contract `Abc123…`."*). They are not jargon — they are opaque-but-copyable handles the user can look up. +8. **Prefer granular `TaskError` variants over `Generic`**: When mapping errors to add context, add a dedicated `TaskError` variant with a `#[source]` field rather than converting to `TaskError::Generic(format!(...))`. Granular variants preserve the error chain, enable structural matching by callers, and make `Display` / `Debug` separation explicit. `TaskError::Generic` is a last resort for one-off strings with no upstream error to preserve. For `#[source]` fields in SDK-originated error variants, use `Box` — convert upstream types (e.g. `ProtocolError`) via `SdkError::Protocol(e)`. Use the concrete domain type directly for non-SDK errors (e.g. `rusqlite::Error`). Omit `#[source]` entirely when the upstream error carries no useful diagnostic information (e.g. a channel `SendError`). + ## Architecture Overview **Dash Evo Tool** is a cross-platform GUI application (Rust + egui) for interacting with Dash Evolution. It enables DPNS username registration, contest voting, state transition viewing, wallet management, and identity operations across Mainnet/Testnet/Devnet. @@ -181,11 +194,12 @@ User-facing messages (errors, warnings, success, infos) use `MessageBanner` (`sr **Logging**: MessageBanner logs all displayed messages (with details) automatically. Additional logging is unnecessary. -**Error banners**: Never expose raw backend/database errors to users. Use a generic user-friendly message in the banner and attach technical details via `BannerHandle::with_details()`. When the error implements `Display` and its text is user-appropriate, pass it directly to `set_global`; otherwise use a descriptive generic message: +**Error banners**: Never expose raw backend/database errors to users. Use a user-friendly message in the banner and attach technical details via `BannerHandle::with_details()`. When the error implements `Display` and its text is user-appropriate, pass it directly to `set_global`; otherwise write a descriptive, actionable message: ```rust MessageBanner::set_global(ctx, "Failed to load token balances", MessageType::Error) .with_details(e); ``` +Consider whether a repeated or reused message belongs in a dedicated `TaskError` variant instead of being written as a string literal at the callsite. A variant centralises the wording, keeps `Display` / `Debug` separation clean, and makes the error testable. This is a soft guideline — a one-off screen-level message that wraps no upstream error is fine as a literal; errors that originate in backend tasks should generally live in `TaskError`. ## Database diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index b05392723..359ec570b 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -5,7 +5,12 @@ //! `From` → backwards compatible with existing `Result` code. //! Parses known error patterns into typed variants automatically. +use dash_sdk::Error as SdkError; use dash_sdk::dashcore_rpc; +use dash_sdk::dpp::ProtocolError; +use dash_sdk::dpp::consensus::ConsensusError; +use dash_sdk::dpp::consensus::state::state_error::StateError; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use thiserror::Error; /// Dash Core RPC error code: wallet file not specified (multi-wallet node). @@ -46,6 +51,13 @@ pub enum TaskError { #[error(transparent)] Sqlite(#[from] rusqlite::Error), + /// Failed to persist an identity update to the local database. + #[error("Could not save identity changes. Check available disk space and retry.")] + IdentitySaveError { + #[source] + source: rusqlite::Error, + }, + /// Tokio task join errors. #[error(transparent)] JoinError(#[from] tokio::task::JoinError), @@ -63,17 +75,112 @@ pub enum TaskError { /// Duplicate identity public key — the key data already exists on the platform. #[error("This public key is already registered on the platform. Try a different key.")] - DuplicateIdentityPublicKey, + DuplicateIdentityPublicKey { + /// The original SDK error returned by the broadcast API. + #[source] + source_error: Box, + }, /// Duplicate identity public key ID — the key hash is already taken platform-wide. - #[error("This key hash is already registered on the platform. Try a different key.")] - DuplicateIdentityPublicKeyId, + #[error("This key is already registered on the platform. Try a different key.")] + DuplicateIdentityPublicKeyId { + /// The original SDK error returned by the broadcast API. + #[source] + source_error: Box, + }, /// Identity public key conflicts with an existing key's unique contract bounds. #[error( "This key conflicts with an existing key bound to contract {contract_id}. Use a different key or purpose." )] - IdentityPublicKeyContractBoundsConflict { contract_id: String }, + IdentityPublicKeyContractBoundsConflict { + contract_id: String, + /// The original SDK error returned by the broadcast API. + #[source] + source_error: Box, + }, + + /// The identity could not be found in the local wallet database. + #[error( + "This identity could not be found in your local wallet. Try refreshing your identities list." + )] + IdentityNotFoundLocally, + + /// Failed to build the identity update state transition. + #[error("Could not build the key update transaction. Please retry.")] + IdentityUpdateTransitionError { + #[source] + source_error: Box, + }, + + /// Failed to send a result back to the UI — the receiver was dropped. + #[error("Internal update failed. Please retry the operation.")] + InternalSendError, + + /// Unclassified SDK error — the operation failed for an unrecognised reason. + /// Display is implemented manually via [`sdk_error_user_message`] to inspect + /// the source error and produce an actionable, user-friendly message. + #[error("{}", sdk_error_user_message(source_error))] + SdkError { + #[source] + source_error: Box, + }, +} + +/// Produce a user-friendly message by inspecting the SDK error variant. +/// +/// The returned text is shown in `MessageBanner` via `Display`. +/// Technical details remain available through the `#[source]` chain / `Debug`. +/// +/// TODO: Expand match arms as we encounter more SDK error variants in the wild. +/// Each arm should explain *what happened* and *what the user can do*. +fn sdk_error_user_message(error: &SdkError) -> String { + match error { + SdkError::StateTransitionBroadcastError(_) => { + // Known broadcast rejection that didn't match a typed consensus variant + // above (DuplicateKey, DuplicateKeyId, ContractBoundsConflict). + // TODO: classify more consensus causes into dedicated TaskError variants + // so fewer errors reach this fallback. + "The platform rejected this request. Please check your input and try again." + .to_string() + } + SdkError::TimeoutReached(duration, _) => { + format!( + "The operation did not complete within {} seconds. Please retry — it often succeeds on the second attempt.", + duration.as_secs() + ) + } + SdkError::StaleNode(_) => { + "The server you connected to is behind. Please retry — the app will pick a different server automatically.".to_string() + } + SdkError::DapiClientError(_) => { + // TODO: inspect inner DapiClientError for connection refused vs TLS vs DNS. + "Could not connect to the Dash network. Please retry in a few moments.".to_string() + } + SdkError::NoAvailableAddressesToRetry(_) => { + "All Dash network servers are temporarily unreachable. Please wait a minute and retry.".to_string() + } + SdkError::Cancelled(_) => "The operation was cancelled.".to_string(), + SdkError::AlreadyExists(_) => { + "This object already exists on the platform.".to_string() + } + SdkError::NonceOverflow(_) => { + "This identity has reached its maximum number of operations. Please try again later.".to_string() + } + SdkError::IdentityNonceNotFound(_) => { + "The platform has not indexed this identity yet. Please retry in a few moments.".to_string() + } + // TODO: add arms for Protocol (consensus sub-errors), InvalidCreditTransfer, + // MissingDependency, Config, etc. + _ => { + // TODO(i18n/ux): This fallback embeds the raw SDK error Display string, + // which may contain jargon or technical details. Add dedicated arms for + // remaining SdkError variants (Protocol, InvalidCreditTransfer, + // MissingDependency, Config, etc.) and replace {error} with a fixed, + // user-friendly message once each variant's typical causes are understood. + format!("An unexpected error occurred: {error}. Please try again later.") + } + } } impl From for TaskError { @@ -98,9 +205,84 @@ impl From for TaskError { } } +impl From for TaskError { + fn from(error: SdkError) -> Self { + enum ConsensusKind { + DuplicateKey, + DuplicateKeyId, + ContractBoundsConflict(String), + } + + let kind: Option = { + let consensus_error = match &error { + SdkError::StateTransitionBroadcastError(broadcast_err) => { + broadcast_err.cause.as_ref() + } + SdkError::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + _ => None, + }; + + consensus_error + .and_then(|ce| match ce { + ConsensusError::StateError( + StateError::DuplicatedIdentityPublicKeyStateError(_), + ) => Some(ConsensusKind::DuplicateKey), + ConsensusError::StateError( + StateError::DuplicatedIdentityPublicKeyIdStateError(_), + ) => Some(ConsensusKind::DuplicateKeyId), + ConsensusError::StateError( + StateError::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError(e), + ) => Some(ConsensusKind::ContractBoundsConflict( + e.contract_id().to_string(Encoding::Base58), + )), + _ => None, + }) + .or_else(|| { + // Message-based fallback: when the SDK doesn't provide a structured + // consensus cause, inspect the broadcast message text. This handles + // DAPI responses where cause=None but message carries the + // duplicate-key signal — the exact regression guarded by issue #714. + if let SdkError::StateTransitionBroadcastError(broadcast_err) = &error + && broadcast_err.cause.is_none() + { + let msg = broadcast_err.message.to_lowercase(); + if msg.contains("duplicate") { + return Some(ConsensusKind::DuplicateKey); + } + } + None + }) + }; + + let boxed = Box::new(error); + match kind { + Some(ConsensusKind::DuplicateKey) => TaskError::DuplicateIdentityPublicKey { + source_error: boxed, + }, + Some(ConsensusKind::DuplicateKeyId) => TaskError::DuplicateIdentityPublicKeyId { + source_error: boxed, + }, + Some(ConsensusKind::ContractBoundsConflict(contract_id)) => { + TaskError::IdentityPublicKeyContractBoundsConflict { + contract_id, + source_error: boxed, + } + } + None => TaskError::SdkError { + source_error: boxed, + }, + } + } +} + #[cfg(test)] mod tests { use super::*; + use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_id_state_error::DuplicatedIdentityPublicKeyIdStateError; + use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_state_error::DuplicatedIdentityPublicKeyStateError; + use dash_sdk::dpp::consensus::state::identity::identity_public_key_already_exists_for_unique_contract_bounds_error::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError; + use dash_sdk::dpp::identity::Purpose; + use dash_sdk::platform::Identifier; #[test] fn from_string_detects_rpc_error_minus_19() { @@ -162,4 +344,83 @@ mod tests { "Expected Generic, got: {err:?}" ); } + + #[test] + fn from_sdk_error_duplicate_public_key() { + let consensus = + ConsensusError::from(DuplicatedIdentityPublicKeyStateError::new(vec![1, 2])); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::DuplicateIdentityPublicKey { .. })); + } + + #[test] + fn from_sdk_error_duplicate_public_key_id() { + let consensus = ConsensusError::from(DuplicatedIdentityPublicKeyIdStateError::new(vec![3])); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + assert!(matches!( + err, + TaskError::DuplicateIdentityPublicKeyId { .. } + )); + } + + #[test] + fn from_sdk_error_contract_bounds_conflict() { + let contract_id = Identifier::random(); + let identity_id = Identifier::random(); + let consensus = ConsensusError::from( + IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError::new( + identity_id, + contract_id, + Purpose::AUTHENTICATION, + 2, + 1, + ), + ); + let sdk_err = SdkError::from(consensus); + let err = TaskError::from(sdk_err); + let expected_contract_id = contract_id.to_string(Encoding::Base58); + assert!( + matches!(err, TaskError::IdentityPublicKeyContractBoundsConflict { ref contract_id, .. } if *contract_id == expected_contract_id) + ); + } + + #[test] + fn from_sdk_error_broadcast_cause_duplicate_key() { + let consensus = ConsensusError::from(DuplicatedIdentityPublicKeyStateError::new(vec![1])); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 40206, + message: "duplicate key".to_string(), + cause: Some(consensus), + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!(matches!(err, TaskError::DuplicateIdentityPublicKey { .. })); + } + + #[test] + fn from_sdk_error_generic_variant_falls_back_to_sdk_error() { + let sdk_err = SdkError::Generic("connection timeout".to_string()); + let err = TaskError::from(sdk_err); + assert!( + matches!(err, TaskError::SdkError { .. }), + "Expected SdkError, got: {err:?}" + ); + } + + #[test] + fn from_sdk_error_broadcast_cause_none_message_duplicate_falls_back_to_duplicate_key() { + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 40206, + message: "DuplicateIdentityPublicKeyStateError".to_string(), + cause: None, + }; + let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); + let err = TaskError::from(sdk_err); + assert!( + matches!(err, TaskError::DuplicateIdentityPublicKey { .. }), + "Expected DuplicateIdentityPublicKey, got: {err:?}" + ); + } } diff --git a/src/backend_task/identity/add_key_to_identity.rs b/src/backend_task/identity/add_key_to_identity.rs index 59f756c3b..4f5a44446 100644 --- a/src/backend_task/identity/add_key_to_identity.rs +++ b/src/backend_task/identity/add_key_to_identity.rs @@ -8,9 +8,6 @@ use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use dash_sdk::Error as SdkError; use dash_sdk::Sdk; -use dash_sdk::dpp::ProtocolError; -use dash_sdk::dpp::consensus::ConsensusError; -use dash_sdk::dpp::consensus::state::state_error::StateError; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, @@ -22,36 +19,6 @@ use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dash_sdk::platform::{Fetch, Identity}; -fn broadcast_error_message(error: &SdkError) -> String { - let consensus_error = match error { - SdkError::StateTransitionBroadcastError(broadcast_err) => broadcast_err.cause.as_ref(), - SdkError::Protocol(ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), - _ => None, - }; - - if let Some(ce) = consensus_error { - match ce { - ConsensusError::StateError(StateError::DuplicatedIdentityPublicKeyStateError(_)) => { - return TaskError::DuplicateIdentityPublicKey.to_string(); - } - ConsensusError::StateError(StateError::DuplicatedIdentityPublicKeyIdStateError(_)) => { - return TaskError::DuplicateIdentityPublicKeyId.to_string(); - } - ConsensusError::StateError( - StateError::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError(e), - ) => { - return TaskError::IdentityPublicKeyContractBoundsConflict { - contract_id: format!("{}", e.contract_id()), - } - .to_string(); - } - _ => {} - } - } - - format!("Broadcasting error: {}", error) -} - impl AppContext { pub(super) async fn add_key_to_identity( &self, @@ -59,19 +26,17 @@ impl AppContext { mut qualified_identity: QualifiedIdentity, mut public_key_to_add: QualifiedIdentityPublicKey, private_key: [u8; 32], - ) -> Result { + ) -> Result { let new_identity_nonce = sdk .get_identity_nonce(qualified_identity.identity.id(), true, None) - .await - .map_err(|e| format!("Fetch nonce error: {}", e))?; + .await?; let Some(master_key) = qualified_identity.can_sign_with_master_key() else { - return Err("Master key not found".to_string()); + return Err("Master key not found".to_string().into()); }; let master_key_id = master_key.identity_public_key.id(); let identity = Identity::fetch_by_identifier(sdk, qualified_identity.identity.id()) - .await - .map_err(|e| format!("Fetch nonce error: {}", e))? - .ok_or("Identity not found".to_string())?; + .await? + .ok_or(TaskError::IdentityNotFoundLocally)?; qualified_identity.identity = identity; qualified_identity.identity.bump_revision(); public_key_to_add @@ -99,12 +64,11 @@ impl AppContext { sdk.version(), None, ) - .map_err(|e| format!("IdentityUpdateTransition error: {}", e))?; + .map_err(|e| TaskError::IdentityUpdateTransitionError { + source_error: Box::new(SdkError::Protocol(e)), + })?; - let result = state_transition - .broadcast_and_wait(sdk, None) - .await - .map_err(|ref e| broadcast_error_message(e))?; + let result = state_transition.broadcast_and_wait(sdk, None).await?; // Log and handle the proof result tracing::info!("AddKeyToIdentity proof result: {}", result); @@ -157,90 +121,7 @@ impl AppContext { let fee_result = FeeResult::new(estimated_fee, actual_fee); self.update_local_qualified_identity(&qualified_identity) - .map(|_| BackendTaskSuccessResult::AddedKeyToIdentity(fee_result)) - .map_err(|e| format!("Database error: {}", e)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_id_state_error::DuplicatedIdentityPublicKeyIdStateError; - use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_state_error::DuplicatedIdentityPublicKeyStateError; - use dash_sdk::dpp::consensus::state::identity::identity_public_key_already_exists_for_unique_contract_bounds_error::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError; - use dash_sdk::dpp::identity::Purpose; - use dash_sdk::dpp::identifier::Identifier; - - #[test] - fn test_duplicate_public_key_error() { - let consensus = - ConsensusError::from(DuplicatedIdentityPublicKeyStateError::new(vec![1, 2])); - let sdk_err = SdkError::from(consensus); - let msg = broadcast_error_message(&sdk_err); - assert_eq!( - msg, - "This public key is already registered on the platform. Try a different key." - ); - } - - #[test] - fn test_duplicate_public_key_id_error() { - let consensus = ConsensusError::from(DuplicatedIdentityPublicKeyIdStateError::new(vec![3])); - let sdk_err = SdkError::from(consensus); - let msg = broadcast_error_message(&sdk_err); - assert_eq!( - msg, - "This key hash is already registered on the platform. Try a different key." - ); - } - - #[test] - fn test_contract_bounds_conflict_error() { - let contract_id = Identifier::random(); - let identity_id = Identifier::random(); - let consensus = ConsensusError::from( - IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError::new( - identity_id, - contract_id, - Purpose::AUTHENTICATION, - 2, - 1, - ), - ); - let sdk_err = SdkError::from(consensus); - let msg = broadcast_error_message(&sdk_err); - assert_eq!( - msg, - format!( - "This key conflicts with an existing key bound to contract {}. Use a different key or purpose.", - contract_id - ) - ); - } - - #[test] - fn test_broadcast_error_with_cause_duplicate_key() { - // Test the StateTransitionBroadcastError path (the actual production path - // when Platform returns a structured broadcast error with a consensus cause). - let consensus = ConsensusError::from(DuplicatedIdentityPublicKeyStateError::new(vec![1])); - let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { - code: 40206, - message: "duplicate key".to_string(), - cause: Some(consensus), - }; - let sdk_err = SdkError::StateTransitionBroadcastError(broadcast_err); - let msg = broadcast_error_message(&sdk_err); - assert_eq!( - msg, - "This public key is already registered on the platform. Try a different key." - ); - } - - #[test] - fn test_unknown_sdk_error_falls_back() { - let sdk_err = SdkError::Generic("connection timeout".to_string()); - let msg = broadcast_error_message(&sdk_err); - assert!(msg.starts_with("Broadcasting error:")); - assert!(msg.contains("connection timeout")); + .map_err(|e| TaskError::IdentitySaveError { source: e })?; + Ok(BackendTaskSuccessResult::AddedKeyToIdentity(fee_result)) } } diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 4fc440a22..fb47170aa 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -11,7 +11,7 @@ mod top_up_identity; mod transfer; mod withdraw_from_identity; -use super::{BackendTaskSuccessResult, FeeResult}; +use super::{BackendTaskSuccessResult, FeeResult, TaskError}; use crate::app::TaskResult; use crate::context::AppContext; use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, WalletDerivationPath}; @@ -530,42 +530,42 @@ impl AppContext { task: IdentityTask, sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync, - ) -> Result { + ) -> Result { match task { - IdentityTask::LoadIdentity(input) => self.load_identity(sdk, input).await, + IdentityTask::LoadIdentity(input) => Ok(self.load_identity(sdk, input).await?), IdentityTask::WithdrawFromIdentity(qualified_identity, to_address, credits, id) => { - self.withdraw_from_identity(qualified_identity, to_address, credits, id) - .await + Ok(self + .withdraw_from_identity(qualified_identity, to_address, credits, id) + .await?) } IdentityTask::AddKeyToIdentity(qualified_identity, public_key_to_add, private_key) => { self.add_key_to_identity(sdk, qualified_identity, public_key_to_add, private_key) .await } IdentityTask::RegisterIdentity(registration_info) => { - self.register_identity(registration_info).await + Ok(self.register_identity(registration_info).await?) } - IdentityTask::RegisterDpnsName(input) => self.register_dpns_name(sdk, input).await, - IdentityTask::RefreshIdentity(qualified_identity) => self - .refresh_identity(sdk, qualified_identity, sender) - .await - .map_err(|e| format!("Error refreshing identity: {}", e)), - IdentityTask::Transfer(qualified_identity, to_identifier, credits, id) => { - self.transfer_to_identity(qualified_identity, to_identifier, credits, id) - .await + IdentityTask::RegisterDpnsName(input) => { + Ok(self.register_dpns_name(sdk, input).await?) } - IdentityTask::SearchIdentityFromWallet(wallet, identity_index) => { - self.load_user_identity_from_wallet(sdk, wallet, identity_index, sender) - .await + IdentityTask::RefreshIdentity(qualified_identity) => { + self.refresh_identity(sdk, qualified_identity, sender).await } - IdentityTask::SearchIdentitiesUpToIndex(wallet, max_identity_index) => { - self.load_user_identities_up_to_index(sdk, wallet, max_identity_index, sender) - .await - } - IdentityTask::SearchIdentityByDpnsName(dpns_name, wallet_seed_hash) => { - self.load_identity_by_dpns_name(sdk, dpns_name, wallet_seed_hash) - .await + IdentityTask::Transfer(qualified_identity, to_identifier, credits, id) => Ok(self + .transfer_to_identity(qualified_identity, to_identifier, credits, id) + .await?), + IdentityTask::SearchIdentityFromWallet(wallet, identity_index) => Ok(self + .load_user_identity_from_wallet(sdk, wallet, identity_index, sender) + .await?), + IdentityTask::SearchIdentitiesUpToIndex(wallet, max_identity_index) => Ok(self + .load_user_identities_up_to_index(sdk, wallet, max_identity_index, sender) + .await?), + IdentityTask::SearchIdentityByDpnsName(dpns_name, wallet_seed_hash) => Ok(self + .load_identity_by_dpns_name(sdk, dpns_name, wallet_seed_hash) + .await?), + IdentityTask::TopUpIdentity(top_up_info) => { + Ok(self.top_up_identity(top_up_info).await?) } - IdentityTask::TopUpIdentity(top_up_info) => self.top_up_identity(top_up_info).await, IdentityTask::TopUpIdentityFromPlatformAddresses { identity, inputs, @@ -588,7 +588,7 @@ impl AppContext { .await } IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames => { - self.refresh_loaded_identities_dpns_names(sender).await + Ok(self.refresh_loaded_identities_dpns_names(sender).await?) } } } @@ -600,7 +600,7 @@ impl AppContext { qualified_identity: QualifiedIdentity, inputs: BTreeMap, wallet_seed_hash: WalletSeedHash, - ) -> Result { + ) -> Result { use crate::model::fee_estimation::PlatformFeeEstimator; use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddresses; @@ -620,14 +620,20 @@ impl AppContext { wallets .get(&wallet_seed_hash) .cloned() - .ok_or_else(|| "Wallet not found".to_string())? + .ok_or_else(|| TaskError::Generic("Wallet not found".into()))? }; - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + // TODO: Replace Generic with a dedicated TaskError::LockPoisoned variant + // that preserves the PoisonError as #[source] with a user-friendly Display. + let wallet_guard = wallet + .read() + .map_err(|e| TaskError::Generic(e.to_string()))?; // Ensure wallet is open if !wallet_guard.is_open() { - return Err("Wallet must be unlocked to sign Platform transactions".to_string()); + return Err(TaskError::Generic( + "Wallet must be unlocked to sign Platform transactions".into(), + )); } wallet_guard.clone() @@ -641,11 +647,7 @@ impl AppContext { // Execute the top-up let (address_infos, new_balance) = identity .top_up_from_addresses(sdk, inputs, &wallet_clone, None) - .await - .map_err(|e| { - tracing::error!("top_up_from_addresses failed: {}", e); - format!("Failed to top up identity from Platform addresses: {}", e) - })?; + .await?; tracing::info!( "top_up_from_addresses succeeded, new_balance={}", @@ -665,7 +667,7 @@ impl AppContext { // Store the updated identity (use update to preserve wallet association) self.update_local_qualified_identity(&updated_identity) - .map_err(|e| format!("Failed to store updated identity: {}", e))?; + .map_err(|e| TaskError::IdentitySaveError { source: e })?; let fee_result = FeeResult::new(estimated_fee, estimated_fee); Ok(BackendTaskSuccessResult::ToppedUpIdentity( @@ -681,7 +683,7 @@ impl AppContext { qualified_identity: QualifiedIdentity, outputs: BTreeMap, key_id: Option, - ) -> Result { + ) -> Result { use crate::model::fee_estimation::PlatformFeeEstimator; use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddresses; @@ -705,8 +707,7 @@ impl AppContext { &qualified_identity, None, ) - .await - .map_err(|e| format!("Failed to transfer credits to Platform addresses: {}", e))?; + .await?; // Update destination address balances in any wallets that contain them // (using proof-verified data from the SDK response) @@ -749,7 +750,7 @@ impl AppContext { // Store the updated identity (use update to preserve wallet association) self.update_local_qualified_identity(&updated_identity) - .map_err(|e| format!("Failed to store updated identity: {}", e))?; + .map_err(|e| TaskError::IdentitySaveError { source: e })?; let fee_result = FeeResult::new(estimated_fee, actual_fee); Ok(BackendTaskSuccessResult::TransferredCredits(fee_result)) diff --git a/src/backend_task/identity/refresh_identity.rs b/src/backend_task/identity/refresh_identity.rs index ab52f2482..5b7d8701c 100644 --- a/src/backend_task/identity/refresh_identity.rs +++ b/src/backend_task/identity/refresh_identity.rs @@ -1,9 +1,9 @@ use crate::app::TaskResult; +use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::qualified_identity::{IdentityStatus, QualifiedIdentity}; use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Fetch, Identity}; use super::BackendTaskSuccessResult; @@ -14,28 +14,20 @@ impl AppContext { sdk: &Sdk, qualified_identity: QualifiedIdentity, sender: crate::utils::egui_mpsc::SenderAsync, - ) -> Result { + ) -> Result { let refreshed_identity_id = qualified_identity.identity.id(); // Fetch the latest state of the identity from Platform - let maybe_refreshed_identity = Identity::fetch_by_identifier(sdk, refreshed_identity_id) - .await - .map_err(|e| e.to_string())?; + let maybe_refreshed_identity = + Identity::fetch_by_identifier(sdk, refreshed_identity_id).await?; // Get local identities - let mut local_qualified_identities = self - .load_local_qualified_identities() - .map_err(|e| e.to_string())?; + let mut local_qualified_identities = self.load_local_qualified_identities()?; // Find the local identity to update let outdated_identity_index = local_qualified_identities .iter() .position(|qi| qi.identity.id() == refreshed_identity_id) - .ok_or_else(|| { - format!( - "Identity with id {} not found in local identities", - refreshed_identity_id.to_string(Encoding::Base58) - ) - })?; + .ok_or(TaskError::IdentityNotFoundLocally)?; // Remove the outdated identity from local state let mut qualified_identity_to_update = @@ -50,7 +42,6 @@ impl AppContext { .update(IdentityStatus::Active); } None => { - // it is not found and the status allows refresh, update status to NotFound qualified_identity_to_update .status .update(IdentityStatus::NotFound); @@ -58,14 +49,13 @@ impl AppContext { } // Insert the updated identity into local state - self.update_local_qualified_identity(&qualified_identity_to_update) - .map_err(|e| e.to_string())?; + self.update_local_qualified_identity(&qualified_identity_to_update)?; // Send refresh message to refresh the Identities Screen sender .send(TaskResult::Refresh) .await - .map_err(|e| e.to_string())?; + .map_err(|_| TaskError::InternalSendError)?; Ok(BackendTaskSuccessResult::RefreshedIdentity( qualified_identity,