Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/user-stories.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ As a user, I want to register a human-readable username on DPNS so that others c
- Choose identity, enter desired name.
- Cost estimate displayed before confirmation.
- While registration runs, a full-window blocking overlay (UX-001) is shown so the same name cannot be submitted twice; it lowers automatically on success or error.
- Completion feedback distinguishes a username registered for immediate use from a request submitted for community voting.

### DPN-002: View owned usernames [Implemented]
**Persona:** Alex, Priya
Expand Down Expand Up @@ -721,6 +722,16 @@ As a masternode operator, I want my previously scheduled DPNS votes to survive a
- A single unreadable vote row costs only itself: the readable votes in the same batch still import.
- The report of unreadable votes returns on every launch until it is explicitly acknowledged, so a vote whose deadline is still open cannot lose its only notice to a missed or dismissed banner.

### DPN-010: See a pending username registration [Implemented]
**Persona:** Alex

As a user who has requested a username that is not yet awarded, I want to see that the request is pending so that I am not told to "pick a username" for a name I have already chosen.

- A requested-but-unawarded name shows a "Pending" pill next to the identity — on both the Identities list and the Identity Home hero card.
- The hero card shows the requested name with the pill instead of the "No username yet — Pick a username" prompt.
- The onboarding checklist counts the submitted request as completing "Pick a username" while clearly stating that Dash masternodes are voting.
- The pill's tooltip explains that Dash masternodes decide who receives the username and, when the decision time is known, gives an estimated decision time.

---

## DashPay (DPY)
Expand Down
148 changes: 148 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,24 @@ fn clear_scheduled_vote_sweep_guard_on_error(
}
}

fn clear_profile_saving_banner_after_error(ctx: &egui::Context, context: &BackendTaskContext) {
if let Some(identity_id) = context.dashpay_profile_update_identity() {
crate::ui::identity::settings::clear_profile_saving_banner(ctx, &identity_id);
}
}

fn clear_profile_saving_banner_after_success(
ctx: &egui::Context,
context: &BackendTaskContext,
result: &BackendTaskSuccessResult,
) {
if let BackendTaskSuccessResult::DashPayProfileUpdated(saved_id) = result
&& context.dashpay_profile_update_identity() == Some(*saved_id)
{
crate::ui::identity::settings::clear_profile_saving_banner(ctx, saved_id);
}
}

Comment thread
Claudius-Maginificent marked this conversation as resolved.
fn unix_time_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down Expand Up @@ -278,6 +296,15 @@ mod backend_task_join_tests {
use crate::backend_task::tokens::TokenTask;
use crate::utils::egui_mpsc::SenderAsync;

fn profile_update_context(dispatch_id: u64, identity_byte: u8) -> BackendTaskContext {
BackendTaskContext::Dispatched {
dispatch_id,
operation: Box::new(BackendTaskContext::DashPayProfileUpdate(Identifier::from(
[identity_byte; 32],
))),
}
}

#[test]
fn backend_task_error_retains_originating_context() {
let task = BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances));
Expand All @@ -301,6 +328,125 @@ mod backend_task_join_tests {
);
}

#[test]
fn profile_update_error_clears_only_its_saving_banner() {
let ctx = egui::Context::default();
let identity_id = Identifier::from([1; 32]);
crate::ui::identity::settings::show_profile_saving_banner(&ctx, identity_id);
let saving = MessageBanner::set_global(
&ctx,
crate::ui::identity::settings::PROFILE_SAVING,
MessageType::Info,
);

clear_profile_saving_banner_after_error(&ctx, &profile_update_context(1, 1));

assert!(
saving.elapsed().is_none(),
"a failed profile update must dismiss its persistent progress banner"
);
}

#[test]
fn profile_update_success_clears_its_saving_banner() {
let ctx = egui::Context::default();
let identity_id = Identifier::from([1; 32]);
crate::ui::identity::settings::show_profile_saving_banner(&ctx, identity_id);
let saving = MessageBanner::set_global(
&ctx,
crate::ui::identity::settings::PROFILE_SAVING,
MessageType::Info,
);

clear_profile_saving_banner_after_success(
&ctx,
&profile_update_context(1, 1),
&BackendTaskSuccessResult::DashPayProfileUpdated(identity_id),
);

assert!(
saving.elapsed().is_none(),
"a successful profile update must dismiss its persistent progress banner"
);
}

#[test]
fn unrelated_error_does_not_clear_profile_saving_banner() {
let ctx = egui::Context::default();
crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([1; 32]));
let saving = MessageBanner::set_global(
&ctx,
crate::ui::identity::settings::PROFILE_SAVING,
MessageType::Info,
);

clear_profile_saving_banner_after_error(&ctx, &BackendTaskContext::Other);

assert!(saving.elapsed().is_some());
}

#[test]
fn one_identity_error_does_not_clear_another_identity_saving_banner() {
let ctx = egui::Context::default();
let identity_a = profile_update_context(1, 1);
let identity_b = profile_update_context(2, 2);
crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([1; 32]));
crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([2; 32]));
let saving = MessageBanner::set_global(
&ctx,
crate::ui::identity::settings::PROFILE_SAVING,
MessageType::Info,
);

clear_profile_saving_banner_after_error(&ctx, &identity_a);

assert!(
saving.elapsed().is_some(),
"identity A's error must not dismiss identity B's progress banner"
);

clear_profile_saving_banner_after_error(&ctx, &identity_b);
assert!(
saving.elapsed().is_none(),
"identity B's error must dismiss identity B's progress banner"
);
}

#[test]
fn one_identity_success_does_not_clear_another_identity_saving_banner() {
let ctx = egui::Context::default();
let identity_a = profile_update_context(1, 1);
let identity_b = profile_update_context(2, 2);
crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([1; 32]));
crate::ui::identity::settings::show_profile_saving_banner(&ctx, Identifier::from([2; 32]));
let saving = MessageBanner::set_global(
&ctx,
crate::ui::identity::settings::PROFILE_SAVING,
MessageType::Info,
);

clear_profile_saving_banner_after_success(
&ctx,
&identity_a,
&BackendTaskSuccessResult::DashPayProfileUpdated(Identifier::from([1; 32])),
);

assert!(
saving.elapsed().is_some(),
"identity A's success must not dismiss identity B's progress banner"
);

clear_profile_saving_banner_after_success(
&ctx,
&identity_b,
&BackendTaskSuccessResult::DashPayProfileUpdated(Identifier::from([2; 32])),
);
assert!(
saving.elapsed().is_none(),
"identity B's success must dismiss identity B's progress banner"
);
}

#[test]
fn backend_task_success_retains_originating_context() {
let task = BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances));
Expand Down Expand Up @@ -2355,6 +2501,7 @@ impl App for AppState {
result: message,
} => {
let unboxed_message = *message;
clear_profile_saving_banner_after_success(ctx, &context, &unboxed_message);
self.route_contact_request_result_to_hidden_hub(&unboxed_message);
match unboxed_message {
BackendTaskSuccessResult::None => {}
Expand Down Expand Up @@ -2602,6 +2749,7 @@ impl App for AppState {
context,
error: err,
} => {
clear_profile_saving_banner_after_error(ctx, &context);
clear_scheduled_vote_sweep_guard_on_error(
&mut self.scheduled_vote_sweeps_in_progress,
&context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ impl AppContext {
}
}

self.refresh_pending_dpns_usernames()?;

sender
.send(TaskResult::unattributed_success(
BackendTaskSuccessResult::RefreshedDpnsContests,
Expand Down
41 changes: 8 additions & 33 deletions src/backend_task/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3104,7 +3104,6 @@ mod tests {
InvalidTokenNameCharacterError, InvalidTokenNameLengthError,
};
use dash_sdk::dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError;
use dash_sdk::dpp::consensus::state::document::duplicate_unique_index_error::DuplicateUniqueIndexError;
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::IdentityInsufficientBalanceError;
Expand Down Expand Up @@ -3585,19 +3584,9 @@ mod tests {

#[test]
fn from_sdk_error_duplicate_unique_index_dpns_named_fields_is_generic() {
let consensus = ConsensusError::from(DuplicateUniqueIndexError::new(
Identifier::random(),
vec![
"normalizedParentDomainName".to_string(),
"normalizedLabel".to_string(),
],
let err = TaskError::from(crate::test_support::duplicate_unique_index_broadcast_error(
vec!["normalizedParentDomainName", "normalizedLabel"],
));
let broadcast_err = dash_sdk::error::StateTransitionBroadcastError {
code: 40105,
message: "duplicate unique index".to_string(),
cause: Some(consensus),
};
let err = TaskError::from(SdkError::StateTransitionBroadcastError(broadcast_err));

assert_eq!(
err.to_string(),
Expand All @@ -3613,20 +3602,13 @@ mod tests {

#[test]
fn from_sdk_error_duplicate_unique_index_other_document_is_actionable() {
let consensus = ConsensusError::from(DuplicateUniqueIndexError::new(
Identifier::random(),
let err = TaskError::from(crate::test_support::duplicate_unique_index_broadcast_error(
vec![
"normalizedParentDomainName".to_string(),
"normalizedLabel".to_string(),
"serialNumber".to_string(),
"normalizedParentDomainName",
"normalizedLabel",
"serialNumber",
],
));
let broadcast_err = dash_sdk::error::StateTransitionBroadcastError {
code: 40105,
message: "duplicate unique index".to_string(),
cause: Some(consensus),
};
let err = TaskError::from(SdkError::StateTransitionBroadcastError(broadcast_err));

assert_eq!(
err.to_string(),
Expand All @@ -3642,17 +3624,10 @@ mod tests {

#[test]
fn from_sdk_error_duplicate_unique_index_boundary_property_counts_are_generic() {
for properties in [vec![], vec!["normalizedLabel".to_string()]] {
let consensus = ConsensusError::from(DuplicateUniqueIndexError::new(
Identifier::random(),
for properties in [vec![], vec!["normalizedLabel"]] {
let err = TaskError::from(crate::test_support::duplicate_unique_index_broadcast_error(
properties,
));
let broadcast_err = dash_sdk::error::StateTransitionBroadcastError {
code: 40105,
message: "duplicate unique index".to_string(),
cause: Some(consensus),
};
let err = TaskError::from(SdkError::StateTransitionBroadcastError(broadcast_err));

match &err {
TaskError::PlatformEntryConflict { source_error } => {
Expand Down
38 changes: 20 additions & 18 deletions src/backend_task/identity/register_dpns_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ use std::collections::BTreeMap;

use crate::backend_task::FeeResult;
use crate::backend_task::error::TaskError;
use crate::{context::AppContext, model::qualified_identity::DPNSNameInfo};
use crate::{
context::AppContext,
model::{dpns::classify_dpns_registration_outcome, qualified_identity::DPNSNameInfo},
};
use bip39::rand::{Rng, SeedableRng, rngs::StdRng};
use dash_sdk::{
Sdk,
Error as SdkError, Sdk,
dpp::{
data_contract::{
accessors::v0::DataContractV0Getters, document_type::accessors::DocumentTypeV0Getters,
Expand Down Expand Up @@ -132,6 +135,12 @@ impl AppContext {
updated_at_core_block_height: None,
transferred_at_core_block_height: None,
});
let outcome = classify_dpns_registration_outcome(
&domain_document_type,
&domain_document,
sdk.version(),
)
.map_err(|error| SdkError::Protocol(*error))?;

let public_key = qualified_identity
.document_signing_key(&preorder_document_type)
Expand All @@ -152,6 +161,8 @@ impl AppContext {
&qualified_identity,
None,
)
// Not rebranded: preorder's only unique index, `saltedDomainHash`, is unrelated to
// usernames, so conflicts keep the generic `PlatformEntryConflict` message.
.await?;

let _ = domain_document
Expand Down Expand Up @@ -249,31 +260,22 @@ impl AppContext {
self.update_local_qualified_identity(&qualified_identity)?;

let fee_result = FeeResult::new(estimated_fee, actual_fee);
Ok(BackendTaskSuccessResult::RegisteredDpnsName(fee_result))
Ok(BackendTaskSuccessResult::RegisteredDpnsName {
outcome,
fee_result,
})
}
}

#[cfg(test)]
mod tests {
use super::*;
use dash_sdk::dpp::consensus::state::document::duplicate_unique_index_error::DuplicateUniqueIndexError;
use dash_sdk::dpp::consensus::ConsensusError::StateError as ConsensusStateError;
use dash_sdk::dpp::consensus::state::state_error::StateError;
use dash_sdk::dpp::consensus::{
ConsensusError, ConsensusError::StateError as ConsensusStateError,
};
use dash_sdk::platform::Identifier;

fn duplicate_unique_index_conflict(properties: Vec<&str>) -> TaskError {
let consensus = ConsensusError::from(DuplicateUniqueIndexError::new(
Identifier::random(),
properties.into_iter().map(str::to_string).collect(),
));
let source_error = Box::new(dash_sdk::Error::StateTransitionBroadcastError(
dash_sdk::error::StateTransitionBroadcastError {
code: 40105,
message: "duplicate unique index".to_string(),
cause: Some(consensus),
},
let source_error = Box::new(crate::test_support::duplicate_unique_index_broadcast_error(
properties,
));

TaskError::PlatformEntryConflict { source_error }
Expand Down
Loading
Loading